# Apilow GameCoin documentation
> GameCoin is the bank of your game: it keeps the virtual currency, the inventory and the orders of web and mobile games. You call it from the browser (publishable key: reads, spends, pays; it can never credit), from your server (secret key: everything), or both. Payments are simulated in the test environment; real payments are not available yet.
---
# Introduction
Source: https://gamecoin.apilow.com/docs
> What GameCoin is, how its pieces fit together, and which of the three ways to integrate it suits your game.
## What GameCoin is
GameCoin is the bank of your game. It keeps the virtual currency, the inventory and the orders of a web or mobile game, so you do not build a ledger, a payment flow or VAT handling yourself.
Every change to a balance is an immutable entry in a ledger, so balances stay exact when a request is retried, sent twice, or refunded. You sell currency packs for euros through a hosted payment page, and players spend the currency on items and on anything else your game offers. You call GameCoin from the browser, from your server, or both, and a browser can never add coins to a balance, except the free coins of a gift code that you issued.
> [!NOTE]
> The test environment is open. Payments there are **simulated**: no money moves. Real payments are not available yet.
## The model in one picture
```text
Studio ─ Game ─ Catalog what you sell, defined once
├─ Currencies gems (paid), gold (free)
├─ Items potion (consumable), fire-sword (durable)
└─ Packs "500 gems + 50 free" for 4.99 €
Players ─ Wallet one balance per currency: paid + bonus
├ Inventory the items a player owns
└ Orders the packs a player bought, in euros
```
You describe the **catalog** once. Your **players** then fill it with balances, items and orders. Each change to a balance is written in the ledger and never altered. Read [Core concepts](/docs/concepts) for the words used everywhere, one by one.
## Three ways to integrate
| | Where your code runs | Key | Good for |
|---|---|---|---|
| **Browser only** | In the browser, with the SDK | Publishable key | A game with no server: itch.io, a game jam, a no-code engine |
| **Server and browser** | Your server holds the rules; the browser shows and spends | Secret key on the server, publishable key and a token in the browser | A game with accounts, rewards and rules to enforce |
| **Server only** | Your server calls the API; no GameCoin code in the game | Secret key | A backend that sells and credits, with its own client |
What changes between them is **who may credit**. A browser can read, spend, buy items and pay for packs, and it can never add to a balance by itself. Only your server, with the secret key, or a paid order can; the one exception is a gift code you issued, which adds free coins once per player. [Security model](/docs/concepts/security-model) explains why.
## Choose your path
| You want to… | Start here |
|---|---|
| Try everything in ten minutes | [Quickstart](/docs/quickstart) |
| Sell currency in a game with no server | [A game without a backend](/docs/guides/no-backend-game) |
| Reward players and run the rules from your server | [A game with a backend](/docs/guides/game-with-backend) |
| Sell packs for euros, and understand VAT and orders | [Selling packs](/docs/guides/selling-packs) |
| Understand balances, refunds and debts | [Wallets and ledger](/docs/concepts/wallets-and-ledger) |
| Give players a ready-made shop page | [The hosted shop](/docs/guides/hosted-shop) |
| Show that shop inside your web game | [Shop widget](/docs/sdk/widget) |
| Look up every call of the browser SDK | [Browser SDK](/docs/sdk/browser) |
| Call the API from your own language | [Server SDKs](/docs/sdk/server) and [API reference](/docs/api) |
## What you need
1. A GameCoin account, and a game in the dashboard. The dashboard is in French for now; this documentation names its labels where you need them.
2. Your keys: the **publishable key** `gc_pk_test_…` for the browser, and the **secret key** `gc_sk_test_…` for your server. Test keys are created with the game.
3. A catalog: at least one currency and one pack. [Quickstart](/docs/quickstart) lists the steps.
## How this documentation is organised
| Section | What it holds |
|---|---|
| **Getting started** | This page, the quickstart and the core concepts |
| **Concepts** | One page per subject, with the exact rules: the ledger, players, keys, idempotency, security, errors, rate limits |
| **Guides** | Complete walkthroughs you can run |
| **Browser SDK** | The reference of `gamecoin.js` and of the shop widget |
| **Server SDKs** | The SDKs for Node.js, Python, PHP, Go and C# |
| **API reference** | Every route, field and error |
## For AI assistants
Every page is also served as plain Markdown: add `.md` to its address (`https://gamecoin.apilow.com/docs/quickstart.md`). [`/llms.txt`](/llms.txt) lists the pages with a one-line description, and [`/llms-full.txt`](/llms-full.txt) holds the whole documentation in one file. The API is also described as an OpenAPI 3.1 document at [`/api/v1/openapi.json`](/api/v1/openapi.json).
---
# Quickstart
Source: https://gamecoin.apilow.com/docs/quickstart
> Sell your game's virtual currency and items in minutes with the browser SDK or the server API, in two paths of three steps each.
## Before you start
GameCoin keeps the ledger, the inventory and the orders of your game. You call it from the browser, from your server, or both. You need a game, a currency and a pack, and your keys.
1. [Create an account](/signup) and a game in the dashboard (« Nouveau jeu »). Your **test keys** are created with the game.
2. Create a paid currency, for example `gems` (« Monnaies », type « Payante »), then a pack that sells it, for example `gems-500`: 500 gems and 50 free, 4.99 € (« Packs »). A new pack is a draft: use « Publier ». For items, create one with a price and tick « Achetable depuis le jeu (API cliente) », so that the browser may buy it.
3. Copy your keys from the game's overview or from « Clés d'API »: the **publishable key** `gc_pk_test_…` (safe in a browser) and the **secret key** `gc_sk_test_…` (shown once, kept on your server).
The dashboard is in French for now; the labels above are the ones you will see.
Pick the path that matches your game. You can start with A and move to B later: both end with the same SDK calls.
| Path | For | How it works |
|---|---|---|
| [A. No backend](#path-a-no-backend) | A browser-only game: itch.io, a game jam, a no-code engine | GameCoin creates an anonymous player and remembers it |
| [B. With a backend](#path-b-with-a-backend) | A game whose server knows its players | Your server declares them, credits and spends, and gives the browser a short-lived token |
## Path A: no backend
Everything runs in the browser, with your publishable key. Three steps.
### Step 1: Load the SDK
One file, no dependency, no build step. It adds a global `GameCoin` and talks to the host it was loaded from.
```html title="Load the SDK"
```
### Step 2: Start it
`GameCoin.init` creates an anonymous player on the first visit and remembers it in `localStorage`. On the next visits it asks for a fresh token with the remembered secret. Same browser, same player, same balance.
```html title="Start the SDK"
```
Clearing the browser's data makes a new player. Anonymous players can be turned off in the game's settings (« Réglages »).
### Step 3: Sell a pack and an item
A complete page. `checkout` pays for a pack, `buyItem` pays for an item with the game's currency, and `consume` uses one up. The balance and the inventory in the SDK are updated after every call.
```html title="A page that sells a pack and an item"
Gems: 0
```
Spending without an item ("continue?", "skip the timer", an entry fee):
```javascript
await gc.spend("gems", 5, "continue");
```
> [!NOTE]
> `checkout()` opens the payment window synchronously and resolves when the order is over: `fulfilled` (the coins are in, `gc.balance()` is already up to date), `failed` (declined), or `pending` (the window was closed without paying). If the browser blocks the window, the SDK sends the current tab to the payment page and brings the player back to the same URL. `init` then reads the order, cleans `?order=&status=` from the address and exposes it as `gc.returnedOrder`. Force it with `gc.checkout(sku, { mode: "redirect" })`.
> [!WARNING]
> A player can never credit themselves. The SDK has no `grant`: coins come from a paid order or from your server (path B). The one exception is a **gift code** that you created in the dashboard: `gc.redeemCode("…")` gives free coins and items, once per player, and the value is decided by you, not by the browser. See [Gift codes and creator codes](/docs/guides/codes). Points your game computes locally are yours to keep locally.
The calls of the SDK:
| Call | Returns | Notes |
|---|---|---|
| `GameCoin.init(options)` | the client | Options: `publishableKey` (required), `playerToken`, `onTokenExpired`, `baseUrl`, `storage`, `displayName`, `debug`. |
| `gc.getCatalog()` | `{ currencies, items, packs }` | Active entries only. |
| `gc.getWallet()` | `{ playerId, balances }` | Reads the server. A fresh copy is also in `gc.wallet`. |
| `gc.getInventory()` | `[{ sku, quantity, updatedAt }]` | Owned items only. |
| `gc.balance(currency)` | a number | Cached total; 0 when unknown. Synchronous. |
| `gc.spend(currency, amount, reason?)` | `{ entry, balance }` | Fails with `INSUFFICIENT_FUNDS` when the wallet is too low. |
| `gc.buyItem(sku, quantity = 1)` | `{ entry, balance, item }` | Only items marked as buyable from the game. |
| `gc.consume(sku, quantity = 1)` | `{ item }` | The item is returned even when it reaches 0. |
| `gc.checkout(packSku, options?)` | the order | From a click. `options.mode`: `"redirect"`. |
| `gc.on("change", fn)` | an unsubscribe function | Called with `{ wallet, inventory, reason }` when the cached wallet or inventory changed. |
| `gc.refresh()` | `{ wallet, inventory }` | Reads the server and updates the cache. |
Every option and every error is in the [Browser SDK](/docs/sdk/browser) reference. For a longer walkthrough, see [A game without a backend](/docs/guides/no-backend-game).
## Path B: with a backend
Your server is the authority: it declares the players, credits and spends, and gives each browser a token. The examples have a `curl` version and a Node.js version. The URL is this server's own.
Set your secret key, and install the Node.js SDK if you use it:
```bash title="Set your secret key"
export GAMECOIN_SECRET_KEY="gc_sk_test_…" # your secret key: server side only
npm install @apilow/gamecoin # Node.js 18 or later
```
Every Node.js example in this documentation starts from this client, `gamecoin`, and from the `ext` helper. The other server languages are in [Server SDKs](/docs/sdk/server).
```javascript title="The client"
import { GameCoin, ext } from "@apilow/gamecoin";
const gamecoin = new GameCoin(process.env.GAMECOIN_SECRET_KEY, { baseUrl: "https://gamecoin.apilow.com" });
```
### Step 1: Declare a player
A player is your own id behind `ext:`. Any `PUT` or `POST` on an unknown `ext:` player creates it, so there is nothing to synchronize beforehand. The id is URL-encoded **exactly once**: `encodeURIComponent("ext:user-42")` is `ext%3Auser-42`, and the SDK's `ext("user-42")` does it for you.
```bash tab="curl"
curl -X PUT "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"displayName":"Player 42","country":"BE"}'
```
```javascript tab="Node.js"
const player = await gamecoin.players.upsert(ext("user-42"), { displayName: "Player 42", country: "BE" });
console.log(player.kind, player.country); // external BE
```
```python tab="Python"
player = gamecoin.players.upsert(ext("user-42"), display_name="Player 42", country="BE")
print(player.kind, player.country) # external BE
```
```php tab="PHP"
$player = $gamecoin->players->upsert(PlayerRef::ext('user-42'), ['display_name' => 'Player 42', 'country' => 'BE']);
echo $player->kind, ' ', $player->country, "\n"; // external BE
```
```go tab="Go"
name, country := "Player 42", "BE"
player, err := client.Players.Upsert(ctx, gamecoin.Ext("user-42"), &gamecoin.PlayerProfile{DisplayName: &name, Country: &country})
if err != nil {
log.Fatal(err)
}
fmt.Println(player.Kind, *player.Country) // external BE
```
```csharp tab="C#"
var player = await gamecoin.Players.UpsertAsync(PlayerRef.Ext("user-42"), new PlayerProfile { DisplayName = "Player 42", Country = "BE" });
Console.WriteLine($"{player.Kind} {player.Country}"); // external BE
```
```json title="Answer"
{"id":"6ac4199eb5efc66d69f4d301","externalId":"user-42","kind":"external","displayName":"Player 42","country":"BE","blocked":false,"createdAt":"2026-10-05T21:41:50.328Z"}
```
### Step 2: Credit and spend
`wallet/grant` always credits the free (« bonus ») part of the balance; coins bought with real money come only from paid orders. Both calls need an `Idempotency-Key` (see [below](#idempotency)). Build it from the event you are paying for.
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: welcome-user-42" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":100,"reason":"welcome-bonus"}'
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/spend" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: revive-user-42-1" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":30,"reason":"revive"}'
```
```javascript tab="Node.js"
await gamecoin.wallet.grant(ext("user-42"), { currency: "gems", amount: 100, reason: "welcome-bonus" }, { idempotencyKey: "welcome-user-42" });
const { balance } = await gamecoin.wallet.spend(ext("user-42"), { currency: "gems", amount: 30, reason: "revive" }, { idempotencyKey: "revive-user-42-1" });
console.log(balance.total); // 70
```
```python tab="Python"
gamecoin.wallet.grant(ext("user-42"), currency="gems", amount=100, reason="welcome-bonus", idempotency_key="welcome-user-42")
movement = gamecoin.wallet.spend(ext("user-42"), currency="gems", amount=30, reason="revive", idempotency_key="revive-user-42-1")
print(movement.balance.total) # 70
```
```php tab="PHP"
$gamecoin->wallet->grant(PlayerRef::ext('user-42'), ['currency' => 'gems', 'amount' => 100, 'reason' => 'welcome-bonus'], ['idempotency_key' => 'welcome-user-42']);
$movement = $gamecoin->wallet->spend(PlayerRef::ext('user-42'), ['currency' => 'gems', 'amount' => 30, 'reason' => 'revive'], ['idempotency_key' => 'revive-user-42-1']);
echo $movement->balance->total, "\n"; // 70
```
```go tab="Go"
if _, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), gamecoin.GrantParams{Currency: "gems", Amount: 100, Reason: "welcome-bonus"}, gamecoin.WithIdempotencyKey("welcome-user-42")); err != nil {
log.Fatal(err)
}
spent, err := client.Wallet.Spend(ctx, gamecoin.Ext("user-42"), gamecoin.SpendParams{Currency: "gems", Amount: 30, Reason: "revive"}, gamecoin.WithIdempotencyKey("revive-user-42-1"))
if err != nil {
log.Fatal(err)
}
fmt.Println(spent.Balance.Total) // 70
```
```csharp tab="C#"
await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), new GrantRequest { Currency = "gems", Amount = 100, Reason = "welcome-bonus" }, new RequestOptions { IdempotencyKey = "welcome-user-42" });
var spent = await gamecoin.Wallet.SpendAsync(PlayerRef.Ext("user-42"), new SpendRequest { Currency = "gems", Amount = 30, Reason = "revive" }, new RequestOptions { IdempotencyKey = "revive-user-42-1" });
Console.WriteLine(spent.Balance.Total); // 70
```
```json title="Answer to the spend"
{"entry":{"id":"6ac4199e1be9a8701b926925","type":"spend","currency":"gems","paidDelta":0,"bonusDelta":-30,
"balanceAfter":{"paid":0,"bonus":70,"total":70},"reason":"revive","metadata":{},"ref":{},"createdAt":"2026-10-05T21:41:50.922Z"},
"balance":{"currency":"gems","paid":0,"bonus":70,"total":70}}
```
Spending more than the balance answers `409 INSUFFICIENT_FUNDS` and changes nothing. Read a balance with `GET /players/ext%3Auser-42/wallet` and the history with `GET …/ledger`.
### Step 3: Give the browser a token, then use the SDK
The token (`gc_pt_…`, valid one hour) lets the browser act as that one player with your publishable key. Ask for it from your server, never from the browser with the secret key.
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/tokens" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const { token, expiresAt } = await gamecoin.players.createToken(ext("user-42"));
console.log(token.startsWith("gc_pt_"), expiresAt.toISOString()); // true 2026-10-05T22:41:51.000Z
```
```python tab="Python"
token = gamecoin.players.create_token(ext("user-42"))
print(token.token.startswith("gc_pt_"), token.expires_at.isoformat()) # True 2026-10-05T22:41:51+00:00
```
```php tab="PHP"
$token = $gamecoin->players->createToken(PlayerRef::ext('user-42'));
echo var_export(str_starts_with($token->token, 'gc_pt_'), true), ' ', $token->expiresAt->format(DATE_ATOM), "\n"; // true 2026-10-05T22:41:51+00:00
```
```go tab="Go"
token, err := client.Players.CreateToken(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
fmt.Println(strings.HasPrefix(token.Token, "gc_pt_"), token.ExpiresAt.Format(time.RFC3339)) // true 2026-10-05T22:41:51Z
```
```csharp tab="C#"
var token = await gamecoin.Players.CreateTokenAsync(PlayerRef.Ext("user-42"));
Console.WriteLine($"{token.Token.StartsWith("gc_pt_")} {token.ExpiresAt:u}"); // True 2026-10-05 22:41:51Z
```
```json title="Answer"
{"token":"gc_pt_…","expiresAt":"2026-10-05T22:41:51.000Z"}
```
Put that call behind an endpoint of your own, behind your login:
```javascript title="A token endpoint on your server (Express-style)"
app.get("/api/gamecoin-token", async (req, res) => {
const { token } = await gamecoin.players.createToken(ext(req.user.id)); // the logged-in player only
res.json({ token });
});
```
Then start the browser SDK with it:
```html title="Start the SDK with the token"
```
With `playerToken` the SDK never calls the player-creation routes. When the token is rejected it calls `onTokenExpired` once, then replays the request with the same idempotency key.
> [!TIP]
> A refund takes back the coins and items of an order. It needs the secret key: the browser cannot refund. `POST /orders/{orderId}/refund` with an optional `reason`. See [Refunds](/docs/guides/refunds).
## The two keys
| | Publishable key | Secret key |
|---|---|---|
| Looks like | `gc_pk_test_…` | `gc_sk_test_…` |
| Where it lives | In your game's code, in the browser | On your server only. Never in a browser, an app bundle or a repository |
| How it is sent | `X-GameCoin-Key: gc_pk_…` | `Authorization: Bearer gc_sk_…` |
| Can | Read the catalog, create an anonymous player. With that player's token (`Authorization: Bearer gc_pt_…`): read their wallet and inventory, spend, buy an item with a currency, consume, pay for a pack. | The whole server API: declare players, grant and spend on any player, give or take items, start checkouts, read and refund orders. |
| Can never | **Credit anything**: no grant, no item grant, no balance correction, no refund. | Nothing is off limits, which is why it stays on your server. |
> [!WARNING]
> Code that runs in a browser can be edited by the player, so GameCoin never lets it add to a balance. Only a completed payment or your server can. That is what keeps a game without a backend safe for real money.
The key also decides the game and the environment (`test` or `live`); nothing in a request body can change that. If a secret key leaks, revoke it and create another one from the dashboard. More in [Keys and environments](/docs/concepts/keys-and-environments) and the [Security model](/docs/concepts/security-model).
## Idempotency
A request that changes a wallet or an inventory, or starts a checkout, must carry an `Idempotency-Key` header (1 to 100 printable ASCII characters). The same key with the same request returns the original answer, with `Idempotent-Replayed: true` and no second effect. The same key with a different request answers `409 IDEMPOTENCY_CONFLICT`. Keys are kept 30 days. A missing key is a `400 IDEMPOTENCY_KEY_REQUIRED`.
**The SDK does it for you**: one random key per call, reused on every automatic retry (network error, 5xx, expired token). To make a call safe across page reloads, pass your own key as the last argument: `gc.spend("gems", 5, "level-3", { idempotencyKey: "level-3-entry" })`.
**From your server**, derive the key from the event you are paying for, not from a random value: if your request times out and you send it again, the same key guarantees it counts once.
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: level-3-reward-user-42" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":10,"reason":"level-3"}'
# Run it twice: the second answer is identical and carries "Idempotent-Replayed: true". Nothing is credited twice.
```
```javascript tab="Node.js"
const params = { currency: "gems", amount: 10, reason: "level-3" };
const options = { idempotencyKey: "level-3-reward-user-42" };
const first = await gamecoin.wallet.grant(ext("user-42"), params, options);
const again = await gamecoin.wallet.grant(ext("user-42"), params, options);
console.log(first.replayed, again.replayed); // false true: credited once
```
```python tab="Python"
params = {"currency": "gems", "amount": 10, "reason": "level-3"}
key = "level-3-reward-user-42"
first = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)
again = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)
print(first.replayed, again.replayed) # False True: credited once
```
```php tab="PHP"
$params = ['currency' => 'gems', 'amount' => 10, 'reason' => 'level-3'];
$options = ['idempotency_key' => 'level-3-reward-user-42'];
$first = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);
$again = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);
echo var_export($first->replayed, true), ' ', var_export($again->replayed, true), "\n"; // false true: credited once
```
```go tab="Go"
params := gamecoin.GrantParams{Currency: "gems", Amount: 10, Reason: "level-3"}
options := gamecoin.WithIdempotencyKey("level-3-reward-user-42")
first, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
log.Fatal(err)
}
again, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
log.Fatal(err)
}
fmt.Println(first.Replayed, again.Replayed) // false true: credited once
```
```csharp tab="C#"
var grantRequest = new GrantRequest { Currency = "gems", Amount = 10, Reason = "level-3" };
var options = new RequestOptions { IdempotencyKey = "level-3-reward-user-42" };
var first = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);
var again = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);
Console.WriteLine($"{first.Replayed} {again.Replayed}"); // False True: credited once
```
Details and rules: [Idempotency](/docs/concepts/idempotency).
## Common errors
Every error has the same shape; the SDK turns it into a `GameCoinError` with `code`, `status`, `message` and `details`.
```json title="Error body"
{ "error": { "code": "INSUFFICIENT_FUNDS", "message": "Insufficient funds", "details": { "required": 50, "available": 20 } } }
```
| Code | HTTP | Meaning and what to do |
|---|---|---|
| `INSUFFICIENT_FUNDS` | 409 | The wallet (or the item quantity) is too low. `details.required` and `details.available` say by how much: offer a pack. |
| `LIMIT_REACHED` | 409 | An item's ownership cap or a pack's per-player limit is reached. |
| `PLAYER_BLOCKED` | 403 | The player is blocked in the dashboard: every client call and every wallet or inventory write is refused. |
| `UNAUTHENTICATED` | 401 | Unknown or revoked key, or an expired or invalid player token. The SDK renews a token once by itself; if it persists, check the key. |
| `FORBIDDEN` | 403 | Not allowed here: a secret key on a client route, anonymous players turned off, or an item that cannot be bought from the game. |
| `VALIDATION_FAILED` | 400 | The request is invalid. Bodies are strict: an unknown field is refused. `details.fieldErrors` says which one. |
| `NOT_FOUND` | 404 | Unknown player, currency, item, pack or order for this game and environment. |
| `IDEMPOTENCY_CONFLICT` | 409 | The same `Idempotency-Key` was sent with a different request. |
| `PAYMENT_FAILED` | 402 | The payment was refused, or no payment provider exists for this environment. |
| `RATE_LIMITED` | 429 | Too many requests. `Retry-After` says how many seconds to wait; the SDK waits and retries. |
| `NETWORK_ERROR` | 0 | SDK only: GameCoin could not be reached after three attempts. The operation may or may not have been applied: refresh the wallet, or pass your own `idempotencyKey` so that a retry cannot count twice. |
```javascript title="Handling an error in the browser"
try {
await gc.spend("gems", 50, "continue");
} catch (error) {
if (error.code === "INSUFFICIENT_FUNDS") {
const { required, available } = error.details; // 50, 20
showShop(required - available); // offer a pack
} else {
throw error; // GameCoinError: code, status, message, details
}
}
```
The full table is on [Errors](/docs/concepts/errors).
## Test and live
The key carries the environment: `gc_pk_test_…` and `gc_sk_test_…` work in **test**, `gc_pk_live_…` and `gc_sk_live_…` in **live**. The catalog (currencies, items, packs) is shared; players, balances, ledger and orders are separate in each.
- **Test** is the only environment you can use today. Payments are simulated: the payment page shows « Payer » and « Refuser » buttons (it is in French for now), and no money moves. Test data is disposable.
- **Live** is reserved for verified studios and real payments, which are not available yet. The environment is chosen by the key, so the code you write today does not change between the two.
## Try it
The demo game is a small clicker built on the SDK: it reads your catalog, shows the balance live, sells your packs and items, and lets you consume them. Open it from your game's overview in the dashboard (it passes your test key), or paste a publishable test key on its start screen.
- [Open the demo game](/demo/index.html)
- [Open the dashboard](/studio)
- [Create an account](/signup)
Next: [Core concepts](/docs/concepts), then [Testing](/docs/guides/testing).
---
# Core concepts
Source: https://gamecoin.apilow.com/docs/concepts
> The vocabulary of GameCoin on one page, from currencies and buckets to orders, keys and environments.
## How the pieces fit
GameCoin stores your game's economy in two layers. You describe it once in the catalog; your players then fill it with balances, items and orders.
```text
Studio your account
└─ Game one per game
├─ Catalog (shared by both environments)
│ ├─ Currencies gems (paid) · gold (free)
│ ├─ Items potion (consumable) · fire-sword (durable)
│ └─ Packs gems-500 = 500 gems + 50 free, for 4.99 €
└─ Environments test · live (players, balances and orders are separate in each)
└─ Players
├─ Wallet one balance per currency, split in paid and bonus
├─ Inventory the items the player owns
└─ Orders the packs the player bought with euros
```
The catalog is created in the dashboard, which is in French for now. Everything a player does with it goes through the API or an SDK.
## Currencies
A currency is a name for your game's money: `gems`, `gold`, `credits`. Its **code** has 2 to 24 characters: a lowercase letter, then lowercase letters, digits or `_`. A code never changes.
| Kind | What it means | Example |
|---|---|---|
| **Paid** | Packs can sell it for euros. The catalog flag is `purchasable: true`. | `gems` |
| **Free** | Only your game hands it out: rewards, quests, daily bonuses. | `gold` |
A paid currency can also be given for free. The two kinds differ only in what packs can sell.
## Balances and buckets
A player has one **balance** per currency. Every balance is split in two **buckets**:
| Bucket | Filled by | Why it is kept apart |
|---|---|---|
| `paid` | A pack bought with euros | Real money paid for these coins: a refund takes them back |
| `bonus` | A grant from your server, a reward, the free part of a pack | Free coins: no refund applies to them |
`total` is `paid + bonus`. All amounts are positive whole numbers; there are no decimals. By default a spend takes the free coins first. The rules are on [Wallets and ledger](/docs/concepts/wallets-and-ledger).
## Items
An item is something a player owns: a potion, a skin, a sword. You sell it for a currency, never for euros.
| Field | Meaning |
|---|---|
| `sku` | Its code, for example `potion`. 1 to 48 characters from `a-z 0-9 _ . -`. |
| `type` | `consumable` can be used up with `consume`. `durable` stays: a player keeps a skin or a sword. |
| `price` | An amount in a currency, or `null` when the item cannot be bought with a currency. |
| `maxOwned` | The most a player can own. A durable item defaults to 1; a consumable has no limit. |
| `clientPurchasable` | Whether the browser may buy it. If `false`, only your server can. |
## Packs
A pack is what a player buys with euros. It gives currency, items, or both. A pack has a price in euros, **VAT included**, from 0.50 € to 500.00 €, and an optional `maxPerPlayer`.
Each line of `grants` has an `amount` that goes to the `paid` bucket and a `bonus` that goes to the `bonus` bucket. The pack `gems-500` has `amount: 500` and `bonus: 50`: the player pays for 500 gems and receives 50 more.
## Orders
An order is one purchase of a pack. It freezes a copy of the pack and the VAT at the moment of purchase, so changing the catalog later never changes an order.
| Status | Meaning |
|---|---|
| `pending` | Created; the player has not paid yet. It expires after one hour. |
| `fulfilled` | Paid and delivered: the currency and items are in the player's account. |
| `refunded` | Refunded: the currency and items were taken back. |
| `failed` | The payment was declined. |
| `expired` | Nobody paid within the hour. |
| `canceled`, `chargeback` | Reserved for later. You will not see them yet. |
| `paid` | A passing state: payment and delivery happen together, so you read `fulfilled`. |
The full life of an order is on [Selling packs](/docs/guides/selling-packs).
## Players
| Kind | How it is named | Created by |
|---|---|---|
| **Your player** (`external`) | `ext:`, with the id your game already uses | Your server, the first time it writes to that id |
| **Anonymous player** (`hosted`) | The id GameCoin gave it | The browser SDK, for a game without a backend |
A player id is a GameCoin id or `ext:` plus your own id, and it is URL-encoded exactly once. The details are on [Players and tokens](/docs/concepts/players-and-tokens).
## Keys, tokens and environments
| Name | Looks like | Lives in | Can |
|---|---|---|---|
| **Secret key** | `gc_sk_test_…` | Your server only | Everything the server API offers |
| **Publishable key** | `gc_pk_test_…` | The game, in the browser | Read the catalog, create an anonymous player |
| **Player token** | `gc_pt_…` | The browser, for one hour | Act as one player: read, spend, buy, consume, pay |
The key also picks the **environment**: `test` (simulated payments, disposable data) or `live` (reserved for verified studios). The catalog is shared; players, balances, ledger and orders are separate in each. See [Keys and environments](/docs/concepts/keys-and-environments).
> [!WARNING]
> A browser can never add to a balance by itself. Only your server (with the secret key) or a paid order can, with one exception: a gift code that you issued adds free coins, once per player. This is the rule that keeps a game without a backend safe. Read [Security model](/docs/concepts/security-model).
## Ledger
The **ledger** is the list of every change to every balance. Each entry is final: it is never edited or deleted. It says what changed in each bucket, the balance after, why, and which order or item it came from. The balance of a player always equals the sum of their entries.
## Where next
- Go through [the quickstart](/docs/quickstart) to see these pieces work.
- Read one page per subject, starting with [Wallets and ledger](/docs/concepts/wallets-and-ledger).
- Pick a [guide](/docs/guides/no-backend-game) that matches your game.
---
# Wallets and ledger
Source: https://gamecoin.apilow.com/docs/concepts/wallets-and-ledger
> How balances are stored, which coins are spent first, what a refund does to a balance, and how to read the history of every change.
## One balance, two buckets
A player has one balance per currency. Each balance holds two whole numbers: `paid` and `bonus`. Their sum is `total`. Reading a wallet returns one line per currency of the game, zeros included. The player must exist: a read for an `ext:` id that was never written answers `404 NOT_FOUND`, and the first write creates it.
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const wallet = await gamecoin.wallet.get(ext("user-42"));
console.log(wallet.balances);
```
```python tab="Python"
wallet = gamecoin.wallet.get(ext("user-42"))
print(wallet.balances)
```
```php tab="PHP"
$wallet = $gamecoin->wallet->get(PlayerRef::ext('user-42'));
echo json_encode($wallet->balances), "
";
```
```go tab="Go"
wallet, err := client.Wallet.Get(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", wallet.Balances)
```
```csharp tab="C#"
var wallet = await gamecoin.Wallet.GetAsync(PlayerRef.Ext("user-42"));
foreach (var balance in wallet.Balances)
{
Console.WriteLine(balance);
}
```
```json title="Answer"
{
"playerId": "6ac41e244870c94d92d6c0ec",
"balances": [
{ "currency": "gems", "paid": 500, "bonus": 50, "total": 550 },
{ "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
]
}
```
| Bucket | What credits it |
|---|---|
| `paid` | The paid part of a pack the player bought (`amount` of a pack line) |
| `bonus` | A grant from your server, and the free part of a pack (`bonus` of a pack line) |
Amounts are whole numbers from 1 to 1 000 000 000 000 (10¹²) per operation. A balance can never go beyond 10¹⁵: an operation that would push it over answers `409 LIMIT_REACHED`.
> [!NOTE]
> A grant always credits the `bonus` bucket. You cannot credit `paid` from the API: `paid` only grows when a player pays for a pack. A body with a field `paid` is refused as an unknown field.
## Every change is a ledger entry
The ledger is an append-only list. Each change to a balance adds one entry, and no entry is ever edited or deleted. An entry carries the change in each bucket (`paidDelta`, `bonusDelta`), the balance right after (`balanceAfter`), a `reason`, your `metadata`, and a `ref` to the order or item it belongs to. Adding up the entries of a player always gives their balance.
| Type | Effect | Created by |
|---|---|---|
| `purchase_credit` | `paid` and `bonus` go up | A pack order that was delivered |
| `grant` | `bonus` goes up | `POST …/wallet/grant`, or a grant made from the dashboard |
| `spend` | Goes down | A spend, or the purchase of an item |
| `refund_clawback` | Goes down | A refund takes back what an order had credited |
| `chargeback_clawback` | Goes down | Reserved: nothing creates it yet |
| `adjustment` | Up or down | A manual correction made in the dashboard |
| `gift_code` | `bonus` goes up | A player redeemed a [gift code](/docs/api/codes#redeem-a-gift-code): free coins, never `paid` |
This is a grant and the entry it leaves:
```endpoint
POST /players/{player}/wallet/grant
```
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: daily-reward-user-42-2026-10-05" \
-H "Content-Type: application/json" \
-d '{"currency":"gold","amount":50,"reason":"daily_reward","metadata":{"day":"3"}}'
```
```javascript tab="Node.js"
const { entry, balance } = await gamecoin.wallet.grant(
ext("user-42"),
{ currency: "gold", amount: 50, reason: "daily_reward", metadata: { day: "3" } },
{ idempotencyKey: "daily-reward-user-42-2026-10-05" },
);
console.log(entry.type, entry.bonusDelta, balance.total); // grant 50 50
```
```python tab="Python"
movement = gamecoin.wallet.grant(
ext("user-42"),
currency="gold",
amount=50,
reason="daily_reward",
metadata={"day": "3"},
idempotency_key="daily-reward-user-42-2026-10-05",
)
print(movement.entry.type, movement.entry.bonus_delta, movement.balance.total) # grant 50 50
```
```php tab="PHP"
$movement = $gamecoin->wallet->grant(
PlayerRef::ext('user-42'),
['currency' => 'gold', 'amount' => 50, 'reason' => 'daily_reward', 'metadata' => ['day' => '3']],
['idempotency_key' => 'daily-reward-user-42-2026-10-05'],
);
echo $movement->entry->type, ' ', $movement->entry->bonusDelta, ' ', $movement->balance->total, "\n"; // grant 50 50
```
```go tab="Go"
movement, err := client.Wallet.Grant(
ctx,
gamecoin.Ext("user-42"),
gamecoin.GrantParams{Currency: "gold", Amount: 50, Reason: "daily_reward", Metadata: map[string]string{"day": "3"}},
gamecoin.WithIdempotencyKey("daily-reward-user-42-2026-10-05"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(movement.Entry.Type, movement.Entry.BonusDelta, movement.Balance.Total) // grant 50 50
```
```csharp tab="C#"
var movement = await gamecoin.Wallet.GrantAsync(
PlayerRef.Ext("user-42"),
new GrantRequest { Currency = "gold", Amount = 50, Reason = "daily_reward", Metadata = new Dictionary { ["day"] = "3" } },
new RequestOptions { IdempotencyKey = "daily-reward-user-42-2026-10-05" });
Console.WriteLine($"{movement.Entry.Type} {movement.Entry.BonusDelta} {movement.Balance.Total}"); // grant 50 50
```
```json title="Answer"
{
"entry": {
"id": "6ac419eeb2d9fab61ce3b764",
"type": "grant",
"currency": "gold",
"paidDelta": 0,
"bonusDelta": 50,
"balanceAfter": { "paid": 0, "bonus": 50, "total": 50 },
"reason": "daily_reward",
"metadata": { "day": "3" },
"ref": {},
"createdAt": "2026-10-05T21:43:10.359Z"
},
"balance": { "currency": "gold", "paid": 0, "bonus": 50, "total": 50 }
}
```
`reason` is free text up to 500 characters. `metadata` holds up to 20 string keys; both are only for you and come back unchanged in the ledger.
## Which coins are spent first
A spend takes the `bonus` coins first, then the `paid` coins. Free coins leave first because paid coins can still be refunded: keeping them longer keeps them refundable longer. A game can reverse this in its settings (« Ordre de dépense » in the dashboard, which is in French): spend `paid` first.
A player holds 500 paid and 50 free gems and spends 400:
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/spend" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: big-buy-user-42" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":400,"reason":"big-buy"}'
```
```javascript tab="Node.js"
const { entry, balance } = await gamecoin.wallet.spend(
ext("user-42"),
{ currency: "gems", amount: 400, reason: "big-buy" },
{ idempotencyKey: "big-buy-user-42" },
);
console.log(entry.bonusDelta, entry.paidDelta, balance.total); // -50 -350 150
```
```python tab="Python"
movement = gamecoin.wallet.spend(
ext("user-42"),
currency="gems",
amount=400,
reason="big-buy",
idempotency_key="big-buy-user-42",
)
print(movement.entry.bonus_delta, movement.entry.paid_delta, movement.balance.total) # -50 -350 150
```
```php tab="PHP"
$movement = $gamecoin->wallet->spend(
PlayerRef::ext('user-42'),
['currency' => 'gems', 'amount' => 400, 'reason' => 'big-buy'],
['idempotency_key' => 'big-buy-user-42'],
);
echo $movement->entry->bonusDelta, ' ', $movement->entry->paidDelta, ' ', $movement->balance->total, "\n"; // -50 -350 150
```
```go tab="Go"
movement, err := client.Wallet.Spend(
ctx,
gamecoin.Ext("user-42"),
gamecoin.SpendParams{Currency: "gems", Amount: 400, Reason: "big-buy"},
gamecoin.WithIdempotencyKey("big-buy-user-42"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(movement.Entry.BonusDelta, movement.Entry.PaidDelta, movement.Balance.Total) // -50 -350 150
```
```csharp tab="C#"
var movement = await gamecoin.Wallet.SpendAsync(
PlayerRef.Ext("user-42"),
new SpendRequest { Currency = "gems", Amount = 400, Reason = "big-buy" },
new RequestOptions { IdempotencyKey = "big-buy-user-42" });
Console.WriteLine($"{movement.Entry.BonusDelta} {movement.Entry.PaidDelta} {movement.Balance.Total}"); // -50 -350 150
```
```json title="Answer"
{
"entry": {
"id": "6ac41a1dcdd4ba50e97ba362",
"type": "spend",
"currency": "gems",
"paidDelta": -350,
"bonusDelta": -50,
"balanceAfter": { "paid": 150, "bonus": 0, "total": 150 },
"reason": "big-buy",
"metadata": {},
"ref": {},
"createdAt": "2026-10-05T21:43:57.311Z"
},
"balance": { "currency": "gems", "paid": 150, "bonus": 0, "total": 150 }
}
```
The 50 free gems went first, then 350 paid ones. One spend, one entry, two buckets touched.
## A spend never goes below zero
If the balance is too low, the spend is refused with `409 INSUFFICIENT_FUNDS` and nothing changes. The error says how much was needed and how much was available.
```json title="Answer to a spend that is too high"
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "Insufficient funds",
"details": { "required": 50, "available": 10 }
}
}
```
Use these two numbers to offer a pack: the player is `required - available` short.
Every operation on a wallet is serialized per player. Five spends of 40 gems sent at the same moment to a balance of 90 gems give exactly two successes and three `INSUFFICIENT_FUNDS`; no spend can use the same coins twice.
## What a refund does to a balance
A refund takes back what the order credited: the `paid` and `bonus` coins of its pack lines, plus its items. The player may already have spent some of them. What happens then depends on a game setting (« Après un remboursement » in the dashboard):
| Setting | Name in the dashboard | Result |
|---|---|---|
| **Allow a debt** (default) | « Autoriser une dette » | Everything is taken back. What the player no longer has becomes a **debt**: the balance goes below zero. |
| **Stop at zero** | « S'arrêter à zéro » | Only what is left is taken. The balance never goes below zero; the difference is lost to you, the studio. |
With the default, a player who bought 500 gems (and received 50 free ones), spent 400, then got refunded, ends at -400:
```json title="The wallet after the refund"
{
"playerId": "6ac419fa60e877541898f38d",
"balances": [
{ "currency": "gems", "paid": -400, "bonus": 0, "total": -400 },
{ "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
]
}
```
The refund left one `refund_clawback` entry. Its `ref.orderId` names the order, and the whole 550 appears in `paidDelta` because the debt is carried by `paid`:
```json title="The refund_clawback entry"
{
"id": "6ac41a2706b31e2c42a63f37",
"type": "refund_clawback",
"currency": "gems",
"paidDelta": -550,
"bonusDelta": 0,
"balanceAfter": { "paid": -400, "bonus": 0, "total": -400 },
"reason": null,
"metadata": {},
"ref": { "orderId": "6ac419fbdbe02af6c99c3d47" },
"createdAt": "2026-10-05T21:44:07.015Z"
}
```
A debt follows three rules:
- A player in debt cannot spend: `INSUFFICIENT_FUNDS` answers with `available: 0`.
- A credit pays the debt first. Granting 100 gems to the player above gives `total: -300`, not 100.
- `bonus` is never negative. A negative `paid` always comes with `bonus: 0`.
How to read what a refund could not take back is on [Refunds](/docs/guides/refunds).
## Read the ledger
`GET /players/{player}/ledger` returns the entries of a player, newest first.
```endpoint
GET /players/{player}/ledger
```
| Query | Default | Meaning |
|---|---|---|
| `currency` | all | Only the entries of this currency |
| `limit` | 20 | Page size, from 1 to 100 |
| `cursor` | none | The `nextCursor` of the previous page |
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?currency=gems&limit=2" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const page = await gamecoin.ledger.list(ext("user-42"), { currency: "gems", limit: 2 });
console.log(page.entries.length, page.nextCursor !== null);
// Or let the SDK follow the cursors and fetch each page only when you need it.
for await (const entry of gamecoin.ledger.iterate(ext("user-42"), { currency: "gems", limit: 50 })) {
console.log(entry.createdAt.toISOString(), entry.type, entry.paidDelta + entry.bonusDelta);
}
```
```python tab="Python"
page = gamecoin.ledger.list(ext("user-42"), currency="gems", limit=2)
print(len(page.entries), page.next_cursor is not None)
# Or let the SDK follow the cursors and fetch each page only when you need it.
for entry in gamecoin.ledger.iterate(ext("user-42"), currency="gems", limit=50):
print(entry.created_at.isoformat(), entry.type, entry.paid_delta + entry.bonus_delta)
```
```php tab="PHP"
$page = $gamecoin->ledger->list(PlayerRef::ext('user-42'), ['currency' => 'gems', 'limit' => 2]);
echo count($page->entries), ' ', var_export($page->nextCursor !== null, true), "\n";
// Or let the SDK follow the cursors and fetch each page only when you need it.
foreach ($gamecoin->ledger->iterate(PlayerRef::ext('user-42'), ['currency' => 'gems', 'limit' => 50]) as $entry) {
echo $entry->createdAt->format(DATE_ATOM), ' ', $entry->type, ' ', $entry->paidDelta + $entry->bonusDelta, "\n";
}
```
```go tab="Go"
page, err := client.Ledger.List(ctx, gamecoin.Ext("user-42"), &gamecoin.LedgerListParams{Currency: "gems", Limit: 2})
if err != nil {
log.Fatal(err)
}
fmt.Println(len(page.Entries), page.NextCursor != nil)
// Or let the SDK follow the cursors and fetch each page only when you need it.
it := client.Ledger.Iterate(ctx, gamecoin.Ext("user-42"), &gamecoin.LedgerListParams{Currency: "gems", Limit: 50})
for it.Next() {
entry := it.Entry()
fmt.Println(entry.CreatedAt.Format(time.RFC3339), entry.Type, entry.PaidDelta+entry.BonusDelta)
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
```
```csharp tab="C#"
var page = await gamecoin.Ledger.ListAsync(PlayerRef.Ext("user-42"), new LedgerQuery { Currency = "gems", Limit = 2 });
Console.WriteLine($"{page.Entries.Count} {page.NextCursor is not null}");
// Or let the SDK follow the cursors and fetch each page only when you need it.
await foreach (var entry in gamecoin.Ledger.IterateAsync(PlayerRef.Ext("user-42"), new LedgerQuery { Currency = "gems", Limit = 50 }))
{
Console.WriteLine($"{entry.CreatedAt:u} {entry.Type} {entry.PaidDelta + entry.BonusDelta}");
}
```
The answer ends with the cursor of the next page. On the last page, `nextCursor` is `null`:
```json title="Answer (shortened)"
{
"entries": [ { "id": "6ac41a2706b31e2c42a63f37", "type": "refund_clawback", "…": "…" }, { "id": "6ac41a1dcdd4ba50e97ba362", "type": "spend", "…": "…" } ],
"nextCursor": "MTc5MTIzNjYzNzMxMTo2YWM0MWExZGNkZDRiYTUwZTk3YmEzNjI"
}
```
A cursor is opaque. Pass it back as it is, with the same `currency`. An invalid cursor answers `400 VALIDATION_FAILED` with the field error `CURSOR_INVALID`.
## Where next
- [Idempotency](/docs/concepts/idempotency): make every write safe to retry.
- [Refunds](/docs/guides/refunds): take back an order and read what happened.
- [Errors](/docs/concepts/errors): every code the API can answer.
---
# Players and tokens
Source: https://gamecoin.apilow.com/docs/concepts/players-and-tokens
> How a player is named, when GameCoin creates one for you, and how a browser proves which player it acts for.
## Two kinds of player
| Kind | `kind` | Named by | Who creates it |
|---|---|---|---|
| **Your player** | `external` | `ext:` | Your server, the first time it writes to that id |
| **Anonymous player** | `hosted` | The id GameCoin generated | The browser SDK, for a game without a backend |
Every player also has a **GameCoin id**: 24 hexadecimal characters such as `6ac419fa60e877541898f38d`. It is stable, and it is the id the API puts in every answer.
## Name a player in a URL
In a server API path, `{player}` is either of these:
- a GameCoin id: `6ac419fa60e877541898f38d`;
- the id your game already uses, behind `ext:`: `ext:user-42`.
Your own id is opaque and case-sensitive. It has 1 to 128 characters and no leading or trailing whitespace. GameCoin never reads meaning into it.
**URL-encode the whole reference exactly once.** `ext:user-42` becomes `ext%3Auser-42`. In JavaScript this is `encodeURIComponent("ext:" + id)`. Encoding a second time, or not at all, is the classic mistake. An id with a space, an accent or a slash shows why:
| Your id | Reference | In the path (encoded once) |
|---|---|---|
| `user-42` | `ext:user-42` | `ext%3Auser-42` |
| `Ana María/7` | `ext:Ana María/7` | `ext%3AAna%20Mar%C3%ADa%2F7` |
A reference encoded twice (`ext%253Auser-42`) answers `400 VALIDATION_FAILED` with the field error `PLAYER_REF_INVALID`.
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3AAna%20Mar%C3%ADa%2F7" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
import { ext } from "@apilow/gamecoin";
// ext() builds the reference, and the SDK encodes it exactly once.
const player = await gamecoin.players.get(ext("Ana María/7"));
console.log(player.externalId); // "Ana María/7"
```
```python tab="Python"
from gamecoin import ext
# ext() builds the reference, and the SDK encodes it exactly once.
player = gamecoin.players.get(ext("Ana María/7"))
print(player.external_id) # Ana María/7
```
```php tab="PHP"
use Apilow\GameCoin\PlayerRef;
// PlayerRef::ext() builds the reference, and the SDK encodes it exactly once.
$player = $gamecoin->players->get(PlayerRef::ext('Ana María/7'));
echo $player->externalId, "\n"; // Ana María/7
```
```go tab="Go"
// gamecoin.Ext() builds the reference, and the SDK encodes it exactly once.
player, err := client.Players.Get(ctx, gamecoin.Ext("Ana María/7"))
if err != nil {
log.Fatal(err)
}
fmt.Println(*player.ExternalID) // Ana María/7
```
```csharp tab="C#"
using Apilow.GameCoin;
// PlayerRef.Ext() builds the reference, and the SDK encodes it exactly once.
var player = await gamecoin.Players.GetAsync(PlayerRef.Ext("Ana María/7"));
Console.WriteLine(player.ExternalId); // Ana María/7
```
## GameCoin creates your players
You do not synchronize your players with GameCoin. With a secret key, any `PUT` or `POST` on an `ext:` id that does not exist yet creates the player. Writing to the wallet, the inventory, a purchase or a checkout all count. A `GET` on an unknown id answers `404 NOT_FOUND`.
`PUT /players/{player}` creates a player or updates its profile:
```endpoint
PUT /players/{player}
```
| Field | Meaning |
|---|---|
| `displayName` | A name shown in the dashboard. Up to 200 characters. |
| `email` | The player's e-mail address, if you have one. Up to 254 characters. |
| `country` | An ISO 3166-1 country code, for example `BE`. It sets the default country for VAT on checkouts. |
| `birthYear` | The year of birth, if you know it. |
Every field is optional. A field you leave out is not touched, and `null` clears it. An empty body means `{}`.
```bash tab="curl"
curl -X PUT "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"displayName":"Ana","country":"BE"}'
```
```javascript tab="Node.js"
const player = await gamecoin.players.upsert(ext("user-42"), { displayName: "Ana", country: "BE" });
console.log(player.kind, player.country); // external BE
// null clears a field; a missing field is left alone.
await gamecoin.players.upsert(ext("user-42"), { country: null });
```
```python tab="Python"
player = gamecoin.players.upsert(ext("user-42"), display_name="Ana", country="BE")
print(player.kind, player.country) # external BE
# None clears a field; a missing field is left alone.
gamecoin.players.upsert(ext("user-42"), country=None)
```
```php tab="PHP"
$player = $gamecoin->players->upsert(PlayerRef::ext('user-42'), ['display_name' => 'Ana', 'country' => 'BE']);
echo $player->kind, ' ', $player->country, "\n"; // external BE
// null clears a field; a missing key is left alone.
$gamecoin->players->upsert(PlayerRef::ext('user-42'), ['country' => null]);
```
```go tab="Go"
name, country := "Ana", "BE"
player, err := client.Players.Upsert(ctx, gamecoin.Ext("user-42"), &gamecoin.PlayerProfile{DisplayName: &name, Country: &country})
if err != nil {
log.Fatal(err)
}
fmt.Println(player.Kind, *player.Country) // external BE
// Clear lists the fields to erase; a nil field is left alone.
if _, err := client.Players.Upsert(ctx, gamecoin.Ext("user-42"), &gamecoin.PlayerProfile{Clear: []gamecoin.ProfileField{gamecoin.FieldCountry}}); err != nil {
log.Fatal(err)
}
```
```csharp tab="C#"
var player = await gamecoin.Players.UpsertAsync(PlayerRef.Ext("user-42"), new PlayerProfile { DisplayName = "Ana", Country = "BE" });
Console.WriteLine($"{player.Kind} {player.Country}"); // external BE
// Clear lists the fields to erase; a null property is left alone.
await gamecoin.Players.UpsertAsync(PlayerRef.Ext("user-42"), new PlayerProfile { Clear = PlayerProfileFields.Country });
```
```json title="Answer"
{
"id": "6ac41a8d4c79ef7265372fa9",
"externalId": "user-42",
"kind": "external",
"displayName": "Ana",
"country": "BE",
"blocked": false,
"createdAt": "2026-10-05T21:45:49.967Z"
}
```
`externalId` is your id without the `ext:`. `blocked` is `true` after you block the player in the dashboard.
## Anonymous players
A game with no server cannot name its players. The browser SDK asks GameCoin for an **anonymous player** the first time the game runs. GameCoin answers with:
- the player;
- a `playerSecret`, shown once;
- a first token and its expiry.
```json title="Answer to POST /client/players (secret and token shortened)"
{
"player": {
"id": "6ac41a4259b32e38d077a85f",
"externalId": null,
"kind": "hosted",
"displayName": "Guest",
"country": null,
"blocked": false,
"createdAt": "2026-10-05T21:44:34.398Z"
},
"playerSecret": "…",
"token": "gc_pt_…",
"expiresAt": "2026-10-05T22:44:34.000Z"
}
```
The SDK keeps the player id and the secret in the browser's `localStorage`. On the next visit it trades them for a fresh token, so the same browser is always the same player with the same balance. The secret never leaves that browser, and it cannot be recovered: clearing the browser's data creates a new player.
| Fact | Value |
|---|---|
| Creating an anonymous player | Limited to 30 per hour per IP address |
| Turning it off | The game setting « Autoriser les joueurs anonymes » in the dashboard (in French). `POST /client/players` then answers `403 FORBIDDEN`. |
| Wrong secret, unknown player, or a player that is not anonymous | `401 UNAUTHENTICATED` on `POST /client/players/token` |
An anonymous player has no `ext:` id, so your server can use it only by its GameCoin id.
## Player tokens
A **player token** lets a browser act as exactly one player, with your publishable key. It looks like `gc_pt_…`, lasts **one hour**, and is signed by GameCoin. It names the player, the game and the environment, so it cannot be used on another game or in the other environment.
A token proves which player is calling. It adds no power: a player can read their own wallet, spend, buy an item with a currency, consume an item and pay for a pack, and nothing else. [Security model](/docs/concepts/security-model) lists it all.
Where a token comes from depends on your game:
| Your game | Where the token comes from |
|---|---|
| Has a server | Your server asks GameCoin with the secret key and hands the token to the browser |
| Has no server | The browser SDK gets it by itself, with the anonymous player's secret |
### Issue a token from your server
```endpoint
POST /players/{player}/tokens
```
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/tokens" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const { token, expiresAt } = await gamecoin.players.createToken(ext("user-42"));
console.log(token.startsWith("gc_pt_"), expiresAt); // true 2026-10-05T22:41:51.000Z
```
```python tab="Python"
token = gamecoin.players.create_token(ext("user-42"))
print(token.token.startswith("gc_pt_"), token.expires_at) # True 2026-10-05 22:41:51+00:00
```
```php tab="PHP"
$token = $gamecoin->players->createToken(PlayerRef::ext('user-42'));
echo var_export(str_starts_with($token->token, 'gc_pt_'), true), ' ', $token->expiresAt->format(DATE_ATOM), "\n"; // true 2026-10-05T22:41:51+00:00
```
```go tab="Go"
token, err := client.Players.CreateToken(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
fmt.Println(strings.HasPrefix(token.Token, "gc_pt_"), token.ExpiresAt) // true 2026-10-05 22:41:51 +0000 UTC
```
```csharp tab="C#"
var token = await gamecoin.Players.CreateTokenAsync(PlayerRef.Ext("user-42"));
Console.WriteLine($"{token.Token.StartsWith("gc_pt_")} {token.ExpiresAt:u}"); // True 2026-10-05 22:41:51Z
```
```json title="Answer"
{ "token": "gc_pt_…", "expiresAt": "2026-10-05T22:41:51.000Z" }
```
This call creates the `ext:` player if it does not exist. It needs no idempotency key: asking twice just gives two valid tokens. Keep it behind your own login: whoever can call your token endpoint can act as that player.
### Renew a token
A token is not revocable, so it is kept short. When a request answers `401 UNAUTHENTICATED`, the token is invalid or expired; the two cases cannot be told apart.
- **Anonymous player:** the browser SDK renews the token by itself with the stored secret, then replays the request with the same idempotency key.
- **Player with a server:** pass `onTokenExpired` to the SDK. It calls your function once, takes the token you return, and replays the request. Your function should fetch a new token from your server.
The browser SDK page shows both with code: [Browser SDK](/docs/sdk/browser#expired-tokens).
### Blocking a player
Blocking a player in the dashboard stops them at once, even if their token is still valid. Every client call, and every wallet or inventory write from your server, answers `403 PLAYER_BLOCKED`. Balances and history are kept, and you can unblock the player at any time.
## Where next
- [Keys and environments](/docs/concepts/keys-and-environments): which key goes where.
- [A game without a backend](/docs/guides/no-backend-game) and [A game with a backend](/docs/guides/game-with-backend): both flows, step by step.
---
# Keys and environments
Source: https://gamecoin.apilow.com/docs/concepts/keys-and-environments
> The three credentials of GameCoin, where each one may live, and how the test and live environments are kept apart.
## Three credentials
| Credential | Looks like | Sent as | Lives |
|---|---|---|---|
| **Secret key** | `gc_sk_test_…` or `gc_sk_live_…` | `Authorization: Bearer gc_sk_…` | On your server only |
| **Publishable key** | `gc_pk_test_…` or `gc_pk_live_…` | `X-GameCoin-Key: gc_pk_…` | In your game, in the browser. It is safe to be seen. |
| **Player token** | `gc_pt_…` | `Authorization: Bearer gc_pt_…`, next to the publishable key | In the browser, for one hour |
The secret key opens the **server API**: every player, every wallet, every refund of your game. The publishable key opens the **client API** (`/api/v1/client/**`), and only to read the catalog and to create an anonymous player. With a player token added, the client API also lets that one player act for themselves. The exact rights of each are in the [Security model](/docs/concepts/security-model).
Using the wrong kind of key on a route answers `403 FORBIDDEN`:
```json title="A publishable key on a server route"
{ "error": { "code": "FORBIDDEN", "message": "This endpoint requires a secret API key" } }
```
An unknown, revoked or missing key answers `401 UNAUTHENTICATED`.
## What a key decides
A key belongs to one game and one environment. **That is the only thing that picks the game and the environment**: nothing in a path, a header or a body can change them. A body that tries, with a field such as `gameId`, is refused as a `400 VALIDATION_FAILED`: request bodies are strict, and a field GameCoin does not know is an error.
The environment is also written in the key: `test` in `gc_sk_test_…`, `live` in `gc_sk_live_…`. Moving from test to live means changing the key your server reads, and your code stays the same.
## Create and look after keys
Keys are made in the dashboard, which is in French for now: open your game, then « Clés d'API ». Your two **test** keys are created together with the game.
- A secret key is shown **once**, when you create it. GameCoin keeps only a fingerprint of it, so nobody can show it to you again.
- A publishable key stays visible in the dashboard.
- To replace a key, create a new one, deploy it, then revoke the old one. A revoked key stops working at once.
- Archiving a game switches off all its keys.
Keep the secret key out of your code and out of your repository. Read it from an environment variable or a secret manager:
```bash title="A secret key in the environment"
export GAMECOIN_SECRET_KEY="gc_sk_test_…"
```
If a secret key leaks, create another one and revoke the leaked one without waiting. The browser SDK refuses a secret key on its own, so a `gc_sk_…` pasted into game code fails at startup, which is better than shipping it.
## Test and live
| | `test` | `live` |
|---|---|---|
| Who can use it | Every studio | Studios verified by Apilow only |
| Payments | **Simulated**: the payment page offers "pay" and "decline" and no money moves | Real payments |
| Data | Disposable | Your real players |
| Keys | Created with the game | Created after verification |
The **catalog** (currencies, items, packs) is shared by the two environments: you define it once. **Players, balances, the ledger, the inventory and orders are separate** in each. A player of `test` does not exist in `live`, and a balance never crosses over.
A studio that is not verified cannot create live keys. A live key presented by an unverified studio answers `403 ENV_NOT_ENABLED`.
> [!NOTE]
> The test environment is the only one you can use today. Real payments are not available yet, so `live` is not open. Because the key picks the environment, what you build now needs no change when `live` opens.
In the dashboard, a switch at the top of the Players and Orders pages (« Environnement ») chooses which environment you are looking at.
## Where next
- [Players and tokens](/docs/concepts/players-and-tokens): how a browser gets a token.
- [Testing](/docs/guides/testing): what to try in the test environment.
---
# Idempotency
Source: https://gamecoin.apilow.com/docs/concepts/idempotency
> How to send the same write twice without it counting twice, which routes need a key, and how to choose one.
## The problem
You call GameCoin to grant 100 gems. The connection drops before the answer arrives. Did the grant happen? If you send it again and it did, the player has 200 gems.
An **idempotency key** removes the doubt. You send a unique key with the write. If GameCoin already saw that key with the same request, it does not repeat the write: it sends back the original answer. You can retry as often as you like.
## How it works
Send the key in the `Idempotency-Key` header: 1 to 100 printable ASCII characters (letters, digits, spaces and punctuation, no accents).
| You send | GameCoin answers |
|---|---|
| A new key | Does the write, remembers the answer |
| The **same key with the same request** | The **original answer**, with the header `Idempotent-Replayed: true`. No second effect. |
| The same key with **another request** | `409 IDEMPOTENCY_CONFLICT`. Nothing changes. |
| No key on a route that needs one | `400 IDEMPOTENCY_KEY_REQUIRED` |
| A key that is not 1 to 100 printable ASCII characters | `400 VALIDATION_FAILED` |
Keys are kept for **30 days**.
```bash tab="curl"
curl -i -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: level-3-reward-user-42" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":10,"reason":"level-3"}'
# Run it twice. The second answer is identical and carries "Idempotent-Replayed: true".
# The player received 10 gems once.
```
```javascript tab="Node.js"
const params = { currency: "gems", amount: 10, reason: "level-3" };
const options = { idempotencyKey: "level-3-reward-user-42" };
const first = await gamecoin.wallet.grant(ext("user-42"), params, options);
const again = await gamecoin.wallet.grant(ext("user-42"), params, options);
console.log(first.replayed, again.replayed); // false true
console.log(first.entry.id === again.entry.id); // true: the same entry, not a second one
```
```python tab="Python"
params = {"currency": "gems", "amount": 10, "reason": "level-3"}
key = "level-3-reward-user-42"
first = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)
again = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)
print(first.replayed, again.replayed) # False True
print(first.entry.id == again.entry.id) # True: the same entry, not a second one
```
```php tab="PHP"
$params = ['currency' => 'gems', 'amount' => 10, 'reason' => 'level-3'];
$options = ['idempotency_key' => 'level-3-reward-user-42'];
$first = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);
$again = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);
echo var_export($first->replayed, true), ' ', var_export($again->replayed, true), "\n"; // false true
var_dump($first->entry->id === $again->entry->id); // bool(true): the same entry, not a second one
```
```go tab="Go"
params := gamecoin.GrantParams{Currency: "gems", Amount: 10, Reason: "level-3"}
options := gamecoin.WithIdempotencyKey("level-3-reward-user-42")
first, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
log.Fatal(err)
}
again, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
log.Fatal(err)
}
fmt.Println(first.Replayed, again.Replayed) // false true
fmt.Println(first.Entry.ID == again.Entry.ID) // true: the same entry, not a second one
```
```csharp tab="C#"
var grantRequest = new GrantRequest { Currency = "gems", Amount = 10, Reason = "level-3" };
var options = new RequestOptions { IdempotencyKey = "level-3-reward-user-42" };
var first = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);
var again = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);
Console.WriteLine($"{first.Replayed} {again.Replayed}"); // False True
Console.WriteLine(first.Entry.Id == again.Entry.Id); // True: the same entry, not a second one
```
The second request returns the same entry. The balance moved once.
## Which routes need a key
Every `POST` that changes a wallet or an inventory, or that creates a checkout, requires a key. Reads, `PUT`, and the refund do not.
| Route | Key |
|---|---|
| `POST /players/{player}/wallet/grant` | required |
| `POST /players/{player}/wallet/spend` | required |
| `POST /players/{player}/inventory/grant` | required |
| `POST /players/{player}/inventory/consume` | required |
| `POST /players/{player}/purchases` | required |
| `POST /players/{player}/checkouts` | required |
| `POST /client/me/wallet/spend` | required |
| `POST /client/me/purchases` | required |
| `POST /client/me/inventory/consume` | required |
| `POST /client/me/checkouts` | required |
| `PUT /players/{player}`, `POST …/tokens`, every `GET` | not used |
| `POST /orders/{orderId}/refund` | not used: see below |
A replayed checkout is the one exception to "the original answer": it returns the order in its **current** state (it may have been paid since) and the same payment URL, with status `200` instead of `201`. No second order is created.
## Choose a good key
The key must be **the same for the same event, and different for every other event**. Build it from the thing you are paying for, not from a random value or the clock:
| Good | Why |
|---|---|
| `level-3-reward-user-42` | One reward per player per level, however many times the call is retried |
| `daily-user-42-2026-10-05` | One daily reward per player per day |
| `order-1001` | One checkout for your own order number 1001 |
| Bad | Why |
|---|---|
| A random value made at each attempt | A retry gets a new key, so it counts twice |
| `reward` | Every player and every level shares it: the second use is a conflict |
A key belongs to your game and environment, **not to a player**. The same key sent for two different players is the same key with another request, and answers `409 IDEMPOTENCY_CONFLICT`. Put the player in the key.
A request that **fails** does not use up its key. If a spend answers `INSUFFICIENT_FUNDS` and you retry the same key after the player earned gems, the spend goes through.
## What the SDKs do for you
- The Node.js server SDK and the other server SDKs make a UUID for every call and reuse it for every automatic retry of that call. Pass your own with `idempotencyKey` when a retry of **your** code must be safe too, as above.
- The browser SDK does the same: one random key per call, reused for every replay (network error, `5xx`, expired token). To make a call safe across a page reload, pass your own key as the last argument: `gc.spend("gems", 5, "level-3", { idempotencyKey: "level-3-entry" })`.
- A result that comes from a replay has `replayed: true` in the server SDKs.
## The refund has no key
`POST /orders/{orderId}/refund` takes no idempotency key. It is protected by the state of the order instead: a refunded order cannot be refunded again (`409 CONFLICT`). After a network error or a `5xx`, read the order with `GET /orders/{orderId}` before you try again. See [Refunds](/docs/guides/refunds).
## Where next
- [Errors](/docs/concepts/errors): every code, including the two idempotency errors.
- [Wallets and ledger](/docs/concepts/wallets-and-ledger): what a grant and a spend write.
---
# Security model
Source: https://gamecoin.apilow.com/docs/concepts/security-model
> Who can do what in GameCoin, why a browser can never add coins to a balance, and what the service refuses by design.
## The rule
**A client never credits.** Code that runs in a browser or on a phone can be read and changed by the player. So GameCoin never lets it add to a balance, give an item, correct a balance or refund an order. The browser can only make a player's own balance go **down**, or pay. One exception, by design: a player can **redeem a gift code** your studio created. The value comes from the code, not from the client: it gives free coins only, works once per player, has a limited number of uses, and every refusal looks the same so codes cannot be guessed (see [Codes API](/docs/api/codes)).
That is what makes a game without a backend safe for real money. It also tells you where to put your own rules: whatever decides that a player *earns* something must run on your server.
## What each holder can do
| Holder | Can | Cannot |
|---|---|---|
| **Secret key** (`gc_sk_…`) | The whole server API for its game and environment: declare players, grant and spend, give and consume items, create checkouts, issue tokens, read and refund orders | Reach another game or the other environment |
| **Publishable key** (`gc_pk_…`) | Read the catalog. Create an anonymous player, if the game allows it. | Anything on a player, without a token |
| **Publishable key + player token** (`gc_pt_…`) | As that one player: read their wallet and inventory, **spend**, **buy an item** with a currency, **consume** an item, **pay for a pack**, read their own orders | Grant coins, give items, correct a balance, refund, act for another player |
| **Dashboard session** | What your role in the studio allows | |
Behind each row, GameCoin checks four things on every request:
1. The key says which game and which environment. Nothing in the request can change that.
2. A player token names one player. A token cannot read the order of another player: it answers `404 NOT_FOUND`.
3. Only a secret key reaches the server API, and only a publishable key reaches the client API. The wrong kind answers `403 FORBIDDEN`.
4. A body with a field GameCoin does not know is refused. There is no hidden field that gives more power.
## Why the client cannot credit
Think about a game where the browser could call `grant`. A player opens the developer tools, calls it with `amount: 1000000`, and owns a fortune. If the same game sells currency for money, that fortune is worth money.
With GameCoin, coins enter a balance in only two ways:
- a **paid order**, which GameCoin delivers after a payment;
- a call from **your server** with your secret key.
A player can change what their own browser does, but not what your server decides.
## Reward on your server
A game with a server rewards players like this:
1. The browser tells your server that something happened: "I finished level 3".
2. Your server checks that it is true, using what it knows.
3. Your server calls `grant` with an idempotency key built from the event.
The browser never says how much to credit. Your server does.
A game **without** a server cannot grant coins. It can still keep points of its own (a high score, experience, a progress bar) in the browser, and let players spend GameCoin currency on things. Do not use GameCoin currency as a prize that the browser decides to give out.
## What GameCoin will not do
GameCoin keeps its currencies closed. It has no call for any of these, and none will be added:
| It will not | In plain words |
|---|---|
| **Turn a currency back into money** | Gems can be bought. They cannot be cashed out. Coins and items are for use inside your game. |
| **Move a currency between players** | There is no transfer, gift or trade of balances. Only your server can give coins to a player. |
| **Share a currency between studios** | A currency belongs to one game of one studio. |
| **Take a bet or an entry stake** | No wagering and no stake tournaments, where players pay in to win a prize. |
| **Sell loot boxes for money** | A pack states what it contains. A pack is not a mystery box. |
These limits keep a GameCoin currency a game token and not money. Design your economy within them.
## How the API protects you
| Protection | How |
|---|---|
| Scope | The key picks game and environment; a resource of another game answers `404`, never `403`, so nothing about it is revealed |
| Strict bodies | An unknown field is a `400`; a body is at most 64 KiB |
| Safe retries | Every write that changes a wallet or an inventory needs an [idempotency key](/docs/concepts/idempotency) |
| No double spend | Operations on one wallet run one at a time |
| Return URLs | The `successUrl` and `cancelUrl` of a browser checkout must have the origin of the page that asks, so a script on another site cannot send your players somewhere else |
| CORS | Only the client API answers cross-origin requests, and only to the origins you allow for the game, so a secret key cannot be used from a web page by accident |
| Rate limits | Per key, per player and per IP: see [Rate limits](/docs/concepts/rate-limits) |
| Short tokens | A player token lasts one hour, since it cannot be revoked. Blocking the player stops it anyway. |
| Card data | Payment happens on a hosted page. No card number passes through your game or your server. |
## Checklist before you ship
- The secret key is only on your server, read from the environment, and not in your repository or your game bundle.
- Your server decides every grant, and every grant has an idempotency key made from the event.
- Your token endpoint sits behind your own login, so a player can only get a token for themselves.
- The browser code uses the publishable key only.
- Your game handles `INSUFFICIENT_FUNDS`, `LIMIT_REACHED` and `UNAUTHENTICATED` without crashing. See [Errors](/docs/concepts/errors).
- You tried the whole flow in the [test environment](/docs/guides/testing).
## Where next
- [Keys and environments](/docs/concepts/keys-and-environments)
- [A game with a backend](/docs/guides/game-with-backend): the flow that follows this model.
---
# Errors
Source: https://gamecoin.apilow.com/docs/concepts/errors
> The shape of every error the API returns, the full table of codes, and how to handle the ones your game will meet.
## The shape
Every error has the same JSON body and a matching HTTP status:
```json
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "Insufficient funds",
"details": { "required": 50, "available": 20 }
}
}
```
| Field | Meaning |
|---|---|
| `code` | A stable code in capitals. **Branch on this**, never on `message`. |
| `message` | A sentence in English for developers and logs. It can change; do not show it to players. |
| `details` | Optional. Extra facts, listed per code below. |
Show your players your own text, chosen from the `code`.
## All the codes
| HTTP | Code | When |
|---|---|---|
| 400 | `VALIDATION_FAILED` | The body, the query or the player reference is invalid. See `details.fieldErrors`. |
| 400 | `IDEMPOTENCY_KEY_REQUIRED` | A write that needs an `Idempotency-Key` has none |
| 401 | `UNAUTHENTICATED` | The key is missing, unknown or revoked, or the player token is invalid or expired |
| 402 | `PAYMENT_FAILED` | The payment was refused, or there is no payment provider for this environment |
| 403 | `FORBIDDEN` | This kind of key cannot do this; anonymous players are turned off; the item cannot be bought from the game |
| 403 | `PLAYER_BLOCKED` | The player is blocked |
| 403 | `ENV_NOT_ENABLED` | A live key was used by a studio that is not verified |
| 404 | `NOT_FOUND` | Unknown player, currency, item, pack or order, in this game and environment |
| 409 | `INSUFFICIENT_FUNDS` | The wallet, or the quantity of an item, is too low |
| 409 | `LIMIT_REACHED` | A limit is reached: an item's `maxOwned`, a pack's `maxPerPlayer`, or the balance ceiling |
| 409 | `PACK_NOT_AVAILABLE` | A pack is outside its sale window |
| 409 | `PACK_SOLD_OUT` | The total stock of a pack is used up |
| 409 | `IDEMPOTENCY_CONFLICT` | An idempotency key was reused with another request |
| 409 | `CONFLICT` | The action does not fit the current state, for example refunding an unpaid order or consuming a durable item |
| 429 | `RATE_LIMITED` | Too many requests. See `Retry-After`. |
| 500 | `INTERNAL` | An unexpected error on our side |
The SDKs add three codes for problems that have no HTTP answer:
| Code | HTTP | Meaning |
|---|---|---|
| `NETWORK_ERROR` | 0 | No answer arrived (connection lost, DNS, firewall) |
| `TIMEOUT` | 0 | The SDK gave up waiting |
| `INVALID_RESPONSE` | as received | The answer was not the JSON GameCoin sends, for example an HTML error page from a proxy |
## Details by code
### INSUFFICIENT_FUNDS
`details.required` and `details.available` give the amount you asked for and what the player has. The same code covers an item: consuming more potions than the player owns.
```json
{ "error": { "code": "INSUFFICIENT_FUNDS", "message": "Not enough items", "details": { "required": 5, "available": 1 } } }
```
### LIMIT_REACHED
For an item, `details` has `maxOwned` and `owned`. For a pack, it has `maxPerPlayer` and `bought`.
```json
{ "error": { "code": "LIMIT_REACHED", "message": "Item ownership limit exceeded", "details": { "maxOwned": 1, "owned": 1 } } }
```
### PACK_NOT_AVAILABLE
The pack is not on sale now: its sale has not started, or it has ended. `details.startsAt` and `details.endsAt` are ISO 8601 dates in UTC, or `null` when that side is open.
```json
{ "error": { "code": "PACK_NOT_AVAILABLE", "message": "The pack is not on sale", "details": { "startsAt": "2026-11-01T09:00:00.000Z", "endsAt": null } } }
```
### PACK_SOLD_OUT
The pack has a total stock and all its units are sold or held by pending orders. `details.packSku` names the pack. Units held by a pending order that is never paid come back after one hour.
### VALIDATION_FAILED
`details.fieldErrors` maps each wrong field to a list of messages or codes:
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid request body",
"details": { "fieldErrors": { "paid": ["Unrecognized field"] } }
}
}
```
Request bodies are strict: a field the route does not know is an error, not something that is ignored. `details.fieldErrors` is for developers; do not parse its messages. A few values are stable codes:
| Field error | Meaning |
|---|---|
| `PLAYER_REF_INVALID` | The `{player}` in the path is not a GameCoin id or an `ext:` reference, often because it was encoded twice |
| `COUNTRY_NOT_SELLABLE` | The `country` of a checkout is not a country where packs are sold |
| `URL_INVALID`, `URL_SCHEME_NOT_ALLOWED` | A `successUrl` or `cancelUrl` is not acceptable |
| `ORIGIN_MISMATCH` | A browser checkout's return URL does not have the origin of the page |
| `CURSOR_INVALID` | The `cursor` of a list is not one GameCoin gave |
| `CODE_INVALID` | A gift code (`code`) or a creator code (`creatorCode`) was refused. One answer for every cause (unknown, expired, used up, disabled, already used, not valid for this pack…), so codes cannot be guessed: see [Codes API](/docs/api/codes) |
### RATE_LIMITED
The `Retry-After` header holds the number of seconds to wait. See [Rate limits](/docs/concepts/rate-limits).
### IDEMPOTENCY_CONFLICT and IDEMPOTENCY_KEY_REQUIRED
They carry no `details`. A request that failed never uses up its idempotency key, so you can send it again with the same key. See [Idempotency](/docs/concepts/idempotency).
## Handle errors
```javascript tab="Node.js"
import { ErrorCode, GameCoinError } from "@apilow/gamecoin";
try {
await gamecoin.wallet.spend(ext("user-42"), { currency: "gems", amount: 50 }, { idempotencyKey: "revive-user-42-7" });
} catch (error) {
if (!(error instanceof GameCoinError)) throw error;
if (error.code === ErrorCode.INSUFFICIENT_FUNDS) {
const { required, available } = error.details; // 50, 20
console.log(`short by ${required - available}: offer a pack`);
} else if (error.code === ErrorCode.RATE_LIMITED) {
console.log(`wait ${error.retryAfter} seconds`);
} else {
throw error; // error.code, error.status, error.message, error.details
}
}
```
```python tab="Python"
from gamecoin import ErrorCode, GameCoinError
try:
gamecoin.wallet.spend(ext("user-42"), currency="gems", amount=50, idempotency_key="revive-user-42-7")
except GameCoinError as error:
if error.code == ErrorCode.INSUFFICIENT_FUNDS:
required, available = error.details["required"], error.details["available"] # 50, 20
print(f"short by {required - available}: offer a pack")
elif error.code == ErrorCode.RATE_LIMITED:
print(f"wait {error.retry_after} seconds")
else:
raise # error.code, error.status, error.message, error.details
```
```php tab="PHP"
use Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoinException;
try {
$gamecoin->wallet->spend(PlayerRef::ext('user-42'), ['currency' => 'gems', 'amount' => 50], ['idempotency_key' => 'revive-user-42-7']);
} catch (GameCoinException $e) {
if ($e->errorCode === ErrorCode::INSUFFICIENT_FUNDS) {
['required' => $required, 'available' => $available] = $e->details; // 50, 20
echo 'short by ', $required - $available, ": offer a pack\n";
} elseif ($e->errorCode === ErrorCode::RATE_LIMITED) {
echo "wait {$e->retryAfter} seconds\n";
} else {
throw $e; // $e->errorCode, $e->status, $e->getMessage(), $e->details
}
}
```
```go tab="Go"
_, err = client.Wallet.Spend(ctx, gamecoin.Ext("user-42"), gamecoin.SpendParams{Currency: "gems", Amount: 50}, gamecoin.WithIdempotencyKey("revive-user-42-7"))
var gcErr *gamecoin.Error
switch {
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeInsufficientFunds:
required, _ := gcErr.DetailInt64("required") // 50
available, _ := gcErr.DetailInt64("available") // 20
fmt.Printf("short by %d: offer a pack\n", required-available)
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeRateLimited:
fmt.Printf("wait %.0f seconds\n", gcErr.RetryAfter.Seconds())
case err != nil:
log.Fatal(err) // gcErr.Code, gcErr.Status, gcErr.Message, gcErr.Details
}
```
```csharp tab="C#"
using Apilow.GameCoin;
try
{
await gamecoin.Wallet.SpendAsync(PlayerRef.Ext("user-42"), new SpendRequest { Currency = "gems", Amount = 50 }, new RequestOptions { IdempotencyKey = "revive-user-42-7" });
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.InsufficientFunds)
{
var required = ex.Details["required"].GetInt64(); // 50
var available = ex.Details["available"].GetInt64(); // 20
Console.WriteLine($"short by {required - available}: offer a pack");
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.RateLimited)
{
Console.WriteLine($"wait {ex.RetryAfter?.TotalSeconds} seconds");
}
// Any other GameCoinException goes up: ex.Code, ex.Status, ex.Message, ex.Details
```
```javascript tab="Browser"
try {
await gc.spend("gems", 50, "revive");
} catch (error) {
if (error.code === "INSUFFICIENT_FUNDS") {
const { required, available } = error.details; // 50, 20
showShop(required - available); // offer a pack
} else {
throw error; // a GameCoinError: code, status, message, details
}
}
```
What to do for each code in a game:
| Code | In your game |
|---|---|
| `INSUFFICIENT_FUNDS` | Offer a pack, or a cheaper choice. This is a normal event, not a failure. |
| `LIMIT_REACHED` | Tell the player they already have it, or have bought it the most times |
| `PACK_NOT_AVAILABLE` | Show « coming soon » or « ended » (read the dates in `details`) |
| `PACK_SOLD_OUT` | Show « sold out » |
| `UNAUTHENTICATED` | In a browser, the SDK renews the token once by itself. If it still fails, check the key. On your server, check the secret key and its environment. |
| `PLAYER_BLOCKED` | Show your own "account suspended" message |
| `VALIDATION_FAILED` | A bug in your request. Log `details` and fix the code. |
| `RATE_LIMITED` | Wait `Retry-After` seconds. The SDKs do it for you for short waits. |
| `NETWORK_ERROR` | The write may or may not have happened. Retry with the same idempotency key, or read the balance first. |
| `INTERNAL` | Retry after a pause. If it lasts, report it. |
## Where next
- [Idempotency](/docs/concepts/idempotency): why a retry after `NETWORK_ERROR` is safe.
- [Rate limits](/docs/concepts/rate-limits)
---
# Rate limits
Source: https://gamecoin.apilow.com/docs/concepts/rate-limits
> How many requests each key, player and address may send per period, what a 429 looks like, and how to stay under the limits.
## The limits
Each limit counts requests in a fixed window. The window starts at the top of the period (the top of the minute or of the hour) and the count restarts when it ends.
| What is counted | Limit | Window |
|---|---|---|
| Server API, per **secret key** | 600 requests | 1 minute |
| Client API with a player token, per **player** | 120 requests | 1 minute |
| Client API without a token (catalog, token exchange), per **IP address** | 120 requests | 1 minute |
| Creating an anonymous player, per **IP address** | 30 requests | 1 hour |
| Gift code attempts (right or wrong), per **player** | 10 attempts | 15 minutes |
| Gift code attempts, client API, per **IP address** | 30 attempts | 15 minutes |
| Creator codes typed at checkout, per **player** | 30 attempts | 15 minutes |
| Creator codes typed at checkout, client API, per **IP address** | 60 attempts | 15 minutes |
Two keys do not share a counter, and neither do two players. A busy player cannot slow down the others.
The OpenAPI document (`/api/v1/openapi.json`) is public and has no rate limit.
## When you go over
The request is refused with `429 RATE_LIMITED` and nothing changes. The `Retry-After` header holds the number of seconds to wait.
```http title="A 429 answer"
HTTP/1.1 429 Too Many Requests
Retry-After: 33
Cache-Control: no-store
Content-Type: application/json; charset=utf-8
{"error":{"code":"RATE_LIMITED","message":"Too many requests"}}
```
The order of checks matters: a request is checked for authentication first, then the rate limit, then the idempotency key, then the body. A refused request still counts.
## Staying under the limits
- **Do not poll.** Read a balance when something may have changed, not on a timer. The browser SDK keeps a cached copy: `gc.balance("gems")` costs no request, and `change` events tell you when it moves.
- **Cache the catalog.** It changes when you edit it, not on every frame.
- **Use one token per player session**, and renew it only when it expires (once an hour).
- **Spread bulk work.** A job that grants rewards to thousands of players should pace itself under 600 requests a minute, with a small queue.
## Retrying a 429
Wait for `Retry-After`, then send the same request again. If it is a write, send it with the **same idempotency key**: the retry cannot count twice.
The SDKs do it for you. The server SDKs retry a `429` after `Retry-After` (up to 30 seconds), up to two times by default. The browser SDK waits up to 10 seconds; for a longer wait it stops and raises `RATE_LIMITED`, with the seconds in `error.details.retryAfter`, so a game never freezes.
```javascript tab="Node.js"
import { ErrorCode, GameCoinError } from "@apilow/gamecoin";
try {
await gamecoin.catalog.get();
} catch (error) {
// The SDK already waited and retried; this is what is left.
if (error instanceof GameCoinError && error.code === ErrorCode.RATE_LIMITED) {
console.log(`still limited: try again in ${error.retryAfter} seconds`);
} else {
throw error;
}
}
```
```python tab="Python"
from gamecoin import ErrorCode, GameCoinError
try:
gamecoin.catalog.get()
except GameCoinError as error:
# The SDK already waited and retried; this is what is left.
if error.code == ErrorCode.RATE_LIMITED:
print(f"still limited: try again in {error.retry_after} seconds")
else:
raise
```
```php tab="PHP"
use Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoinException;
try {
$gamecoin->catalog->get();
} catch (GameCoinException $e) {
// The SDK already waited and retried; this is what is left.
if ($e->errorCode === ErrorCode::RATE_LIMITED) {
echo "still limited: try again in {$e->retryAfter} seconds\n";
} else {
throw $e;
}
}
```
```go tab="Go"
_, err = client.Catalog.Get(ctx)
var gcErr *gamecoin.Error
switch {
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeRateLimited:
// The SDK already waited and retried; this is what is left.
fmt.Printf("still limited: try again in %.0f seconds\n", gcErr.RetryAfter.Seconds())
case err != nil:
log.Fatal(err)
}
```
```csharp tab="C#"
using Apilow.GameCoin;
try
{
await gamecoin.Catalog.GetAsync();
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.RateLimited)
{
// The SDK already waited and retried; this is what is left.
Console.WriteLine($"still limited: try again in {ex.RetryAfter?.TotalSeconds} seconds");
}
```
```javascript tab="Browser"
try {
await gc.refresh();
} catch (error) {
if (error.code === "RATE_LIMITED") {
console.log(`try again in ${error.details.retryAfter} seconds`);
} else {
throw error;
}
}
```
## Where next
- [Idempotency](/docs/concepts/idempotency): how to retry a write safely.
- [Errors](/docs/concepts/errors)
---
# A game without a backend
Source: https://gamecoin.apilow.com/docs/guides/no-backend-game
> Build a browser game that sells a currency pack and an item with only the browser SDK and a publishable key, from first line to a working page.
## What you will build
A page where a player buys gems with euros (simulated in test), buys a potion with gems, and drinks it. There is no server of your own: the game talks to GameCoin straight from the browser, with the SDK.
This is the path for a game on itch.io, in a game jam, in a no-code engine, or written with an AI assistant. If your game has its own server, read [A game with a backend](/docs/guides/game-with-backend) instead.
## Before you start
You need a game and a catalog. The dashboard is in French for now.
1. [Create an account](/signup) and a game (« Nouveau jeu »). The two test keys are created with it.
2. Create the paid currency `gems` in « Monnaies » (type « Payante »).
3. Create the item `potion` in « Objets »: type « Consommable », « Prix en monnaie » 20 in `gems`, and tick « Achetable depuis le jeu (API cliente) ». Without that box the browser cannot buy it.
4. Create the pack `gems-500` in « Packs »: `gems` with « Quantité payée » 500 and « Quantité offerte » 50, « Prix (en euros) » 4,99. A new pack is a draft: use « Publier » so players can see it.
5. In « Clés d'API », copy your **publishable key** `gc_pk_test_…`.
The examples use these codes. Use your own codes where yours differ.
| What | Code | Content |
|---|---|---|
| Paid currency | `gems` | |
| Item | `potion` | Consumable, 20 gems, buyable from the game |
| Pack | `gems-500` | 500 gems + 50 free gems, 4.99 € |
Leave « Autoriser les joueurs anonymes » on in the game's settings (« Réglages »). It is on by default, and this path needs it.
## Step 1: Load the SDK and start it
The SDK is one file with no dependency and no build step. It adds a global `GameCoin` and talks to the host it was loaded from.
```html title="index.html"
```
`init` does the work of a login for you. On the first visit it creates an **anonymous player** and keeps its secret in `localStorage`. On the next visits it asks for a fresh token with that secret. The same browser is always the same player, with the same balance. Clearing the browser's data makes a new player.
> [!NOTE]
> Open the page from a web server (a local one is fine), not from a `file://` address. Itch.io and most engines already serve your game over `https://`.
## Step 2: Show the shop from the catalog
Do not copy prices into your code. Ask the catalog: it holds only the active currencies, items and packs.
```javascript
const catalog = await gc.getCatalog();
console.log(catalog.packs[0]);
```
```json title="One pack of the catalog"
{
"sku": "gems-500",
"name": "Bag of gems",
"description": null,
"priceCents": 499,
"currency": "EUR",
"grants": [{ "currency": "gems", "amount": 500, "bonus": 50 }],
"items": [],
"badge": null,
"maxPerPlayer": null
}
```
A pack's price is in **euro cents, VAT included**: `499` is 4.99 €. Divide by 100 to display it.
## Step 3: Keep the screen in sync
The SDK keeps a copy of the player's wallet and inventory. `gc.balance("gems")` and `gc.owned("potion")` read it with no request. The `change` event fires whenever the copy changes, after a spend, a purchase, a consume, a payment or a refresh. Draw your numbers in one function and call it from the event:
```javascript
const render = () => {
document.getElementById("gems").textContent = gc.balance("gems");
document.getElementById("potions").textContent = gc.owned("potion");
};
gc.on("change", render);
render();
```
## Step 4: Sell a pack
`checkout` sells a pack for euros. It opens the payment page in a window and finishes when the order is over. **Call it straight from a click**, or the browser blocks the window.
```javascript
document.getElementById("buy-pack").onclick = async () => {
const order = await gc.checkout("gems-500");
console.log(order.status); // "fulfilled", "failed" or "pending"
};
```
| `order.status` | What happened | What to do |
|---|---|---|
| `fulfilled` | The player paid. The gems are in, and `gc.balance("gems")` is already up to date. | Thank the player |
| `failed` | The payment was declined | Offer to try again |
| `pending` | The player closed the window without paying | Nothing: the order expires by itself after an hour |
In test, the payment page is simulated: it shows the order, the VAT and two buttons, « Payer » and « Refuser ». No money moves. The page is in French for now.
If the browser blocks the window, the SDK sends the current tab to the payment page instead, and brings the player back to the same URL. You can force that with `gc.checkout("gems-500", { mode: "redirect" })`. When the page loads again, `init` reads the order, removes `?order=…&status=…` from the address, and exposes the order as `gc.returnedOrder`. See [Browser SDK](/docs/sdk/browser#checkout).
## Step 5: Spend, buy an item, consume it
Three calls change a balance downwards:
| Call | Use it for |
|---|---|
| `gc.spend("gems", 5, "continue")` | A cost with no item: "continue?", a skip, an entry fee |
| `gc.buyItem("potion", 1)` | Buy an item with its own price, in its own currency |
| `gc.consume("potion", 1)` | Use up a consumable the player owns |
Each one can fail with `INSUFFICIENT_FUNDS`, and that is a normal event, not a bug. Its `details` say how short the player is:
```javascript
async function run(action) {
try {
await action();
} catch (error) {
if (error.code === "INSUFFICIENT_FUNDS") {
const { required, available } = error.details;
say(`You need ${required - available} more.`); // send the player to the shop
} else {
say(`Something went wrong (${error.code}).`);
}
}
}
```
## The whole page
All the steps together. Put your own publishable key in place of `gc_pk_test_…`.
```html title="index.html"
Potion shop
Gems: 0 · Potions: 0
```
Try it: the buttons for the potion fail at first, because the player has no gems. Buy the pack, pay in the simulated page, and the gems appear. Then buy a potion and drink it. The balance and the potion count update on their own.
## What a player cannot do
The SDK has no `grant`, and neither does the API it calls with a publishable key. A player can open the developer tools and call anything the SDK can, but that is only spending their own gems or paying for a pack. They can never add coins to their balance, except the free coins of a gift code that you issued (one use per player). That is why this path is safe for real money. [Security model](/docs/concepts/security-model) explains it.
It also means a game without a backend cannot give gems as a reward. Points that your game computes locally, such as a score, stay in your game. If you want rewards in currency, add a server: [A game with a backend](/docs/guides/game-with-backend).
## If something goes wrong
| You see | Why | Fix |
|---|---|---|
| `publishableKey must be a publishable key` | The key does not start with `gc_pk_` | Copy the publishable key, not the secret one. The SDK refuses a `gc_sk_` key on purpose. |
| `baseUrl is required when the SDK is not loaded from a
```
From here the browser does the same things as in [A game without a backend](/docs/guides/no-backend-game): `gc.spend`, `gc.buyItem`, `gc.consume`, `gc.checkout`. It can read, spend and pay. It still cannot credit anything by itself: the one exception is a [gift code](/docs/guides/codes) that you created, which the player can redeem with `gc.redeemCode(code)` for free coins and items, once. It works with your player token too; if you want your server to decide who may redeem, call the [server route](/docs/api/codes) instead.
When your server grants something while the page is open (a reward after `/api/level-complete`), tell the SDK to read again:
```javascript
await fetch("/api/level-complete", { method: "POST" });
await gc.refresh(); // the "change" event fires and your screen updates
```
## Step 6: Let the server spend when the server must decide
The browser can spend by itself, which is simple and fine for a game where nothing depends on the purchase. When your server enforces the result (an item that unlocks something the server checks, an entry fee for a match your server runs), let the **server** spend, and the browser only asks:
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/purchases" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: potion-user-42-order-9" \
-H "Content-Type: application/json" \
-d '{"sku":"potion","quantity":2}'
```
```javascript tab="Node.js"
const purchase = await gamecoin.purchases.create(
ext("user-42"),
{ sku: "potion", quantity: 2 },
{ idempotencyKey: "potion-user-42-order-9" },
);
console.log(purchase.balance.total, purchase.item.quantity); // 60 2
```
```python tab="Python"
purchase = gamecoin.purchases.create(
ext("user-42"),
sku="potion",
quantity=2,
idempotency_key="potion-user-42-order-9",
)
print(purchase.balance.total, purchase.item.quantity) # 60 2
```
```php tab="PHP"
$purchase = $gamecoin->purchases->create(
PlayerRef::ext('user-42'),
['sku' => 'potion', 'quantity' => 2],
['idempotency_key' => 'potion-user-42-order-9'],
);
echo $purchase->balance->total, ' ', $purchase->item->quantity, "\n"; // 60 2
```
```go tab="Go"
purchase, err := client.Purchases.Create(
ctx,
gamecoin.Ext("user-42"),
gamecoin.PurchaseParams{SKU: "potion", Quantity: 2},
gamecoin.WithIdempotencyKey("potion-user-42-order-9"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(purchase.Balance.Total, purchase.Item.Quantity) // 60 2
```
```csharp tab="C#"
var purchase = await gamecoin.Purchases.CreateAsync(
PlayerRef.Ext("user-42"),
new PurchaseRequest { Sku = "potion", Quantity = 2 },
new RequestOptions { IdempotencyKey = "potion-user-42-order-9" });
Console.WriteLine($"{purchase.Balance.Total} {purchase.Item.Quantity}"); // 60 2
```
`purchases` buys an item with its own price: the wallet is debited and the item added, in one step. If the wallet is too low, the call answers `409 INSUFFICIENT_FUNDS` and nothing changes. The server SDK handles errors with `GameCoinError`; see [Errors](/docs/concepts/errors).
You can also read what your server needs:
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/inventory" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const wallet = await gamecoin.wallet.get(ext("user-42"));
const items = await gamecoin.inventory.list(ext("user-42"));
console.log(wallet.balances.map((b) => `${b.currency}: ${b.total}`), items);
```
```python tab="Python"
wallet = gamecoin.wallet.get(ext("user-42"))
items = gamecoin.inventory.list(ext("user-42"))
print([f"{b.currency}: {b.total}" for b in wallet.balances], items)
```
```php tab="PHP"
$wallet = $gamecoin->wallet->get(PlayerRef::ext('user-42'));
$items = $gamecoin->inventory->list(PlayerRef::ext('user-42'));
echo implode(', ', array_map(static fn ($b) => "{$b->currency}: {$b->total}", $wallet->balances)), "
";
print_r($items);
```
```go tab="Go"
wallet, err := client.Wallet.Get(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
items, err := client.Inventory.List(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
for _, b := range wallet.Balances {
fmt.Printf("%s: %d\n", b.Currency, b.Total)
}
fmt.Printf("%+v\n", items)
```
```csharp tab="C#"
var wallet = await gamecoin.Wallet.GetAsync(PlayerRef.Ext("user-42"));
var items = await gamecoin.Inventory.ListAsync(PlayerRef.Ext("user-42"));
Console.WriteLine(string.Join(", ", wallet.Balances.Select(b => $"{b.Currency}: {b.Total}")));
foreach (var item in items)
{
Console.WriteLine(item);
}
```
## Before you go live
- The secret key lives only in your server's environment.
- `/api/gamecoin-token` sits behind your login, and gives a token only for the logged-in player.
- Every grant has an idempotency key made from the event.
- Your browser code uses the publishable key and the token, nothing else.
- You tried a declined payment, a player without enough gems, and an expired token. See [Testing](/docs/guides/testing).
## Where next
- [Selling packs](/docs/guides/selling-packs): create the checkout from your server.
- [Free currencies and items](/docs/guides/free-currencies-and-items): rewards, items, limits.
- [Security model](/docs/concepts/security-model)
---
# Selling packs
Source: https://gamecoin.apilow.com/docs/guides/selling-packs
> Sell a pack for euros with a checkout and the hosted payment page, from the VAT to the return URL and the life of an order.
## What you will do
A **pack** is what a player buys with euros: currency, items, or both. You sell it in four steps:
1. Create a **checkout**. GameCoin makes an **order** and a payment URL.
2. Send the player to the **payment page**, which is hosted for you.
3. The player pays. GameCoin delivers the currency and items.
4. The player comes back to your game, and you read the order.
The browser SDK does all four with one call, `gc.checkout("gems-500")`. This page is for the steps behind it, and for selling from your server. The examples use the pack `gems-500`: 500 gems and 50 free gems for 4.99 €.
> [!NOTE]
> In the test environment, payments are **simulated**: the payment page offers a button to pay and a button to decline, and no money moves. Real payments are not available yet.
## Create a checkout
```endpoint
POST /players/{player}/checkouts
```
| Field | Required | Meaning |
|---|---|---|
| `packSku` | yes | The code of the pack |
| `successUrl` | no | Where to send the player after a successful payment |
| `cancelUrl` | no | Where to send the player after a declined payment |
| `country` | no | The buyer's country, which decides the VAT. Server API only. |
The call needs an [idempotency key](/docs/concepts/idempotency). It answers `201` with the new order and the URL of the payment page.
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/checkouts" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{"packSku":"gems-500","country":"FR","successUrl":"https://example.com/shop/thanks","cancelUrl":"https://example.com/shop"}'
```
```javascript tab="Node.js"
const { order, checkoutUrl } = await gamecoin.checkouts.create(
ext("user-42"),
{
packSku: "gems-500",
country: "FR",
successUrl: "https://example.com/shop/thanks",
cancelUrl: "https://example.com/shop",
},
{ idempotencyKey: "order-1001" },
);
console.log(order.status, order.vatRateBp, checkoutUrl); // pending 2000 https://gamecoin.apilow.com/pay/…?t=…
```
```python tab="Python"
checkout = gamecoin.checkouts.create(
ext("user-42"),
pack_sku="gems-500",
country="FR",
success_url="https://example.com/shop/thanks",
cancel_url="https://example.com/shop",
idempotency_key="order-1001",
)
print(checkout.order.status, checkout.order.vat_rate_bp, checkout.checkout_url) # pending 2000 https://gamecoin.apilow.com/pay/…?t=…
```
```php tab="PHP"
$checkout = $gamecoin->checkouts->create(
PlayerRef::ext('user-42'),
[
'pack_sku' => 'gems-500',
'country' => 'FR',
'success_url' => 'https://example.com/shop/thanks',
'cancel_url' => 'https://example.com/shop',
],
['idempotency_key' => 'order-1001'],
);
echo $checkout->order->status, ' ', $checkout->order->vatRateBp, ' ', $checkout->checkoutUrl, "\n"; // pending 2000 https://gamecoin.apilow.com/pay/…?t=…
```
```go tab="Go"
checkout, err := client.Checkouts.Create(
ctx,
gamecoin.Ext("user-42"),
gamecoin.CheckoutParams{
PackSKU: "gems-500",
Country: "FR",
SuccessURL: "https://example.com/shop/thanks",
CancelURL: "https://example.com/shop",
},
gamecoin.WithIdempotencyKey("order-1001"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(checkout.Order.Status, checkout.Order.VatRateBp, checkout.CheckoutURL) // pending 2000 https://gamecoin.apilow.com/pay/…?t=…
```
```csharp tab="C#"
var checkout = await gamecoin.Checkouts.CreateAsync(
PlayerRef.Ext("user-42"),
new CheckoutRequest
{
PackSku = "gems-500",
Country = "FR",
SuccessUrl = "https://example.com/shop/thanks",
CancelUrl = "https://example.com/shop",
},
new RequestOptions { IdempotencyKey = "order-1001" });
Console.WriteLine($"{checkout.Order.Status} {checkout.Order.VatRateBp} {checkout.CheckoutUrl}"); // pending 2000 https://gamecoin.apilow.com/pay/…?t=…
```
```javascript tab="Browser"
// The browser needs no key, no country and no URLs: it uses the page it is on.
const order = await gc.checkout("gems-500");
console.log(order.status); // "fulfilled", "failed" or "pending"
```
```json title="Answer to the server call"
{
"order": {
"id": "6ac419fbdbe02af6c99c3d47",
"playerId": "6ac419fa60e877541898f38d",
"status": "pending",
"pack": { "sku": "gems-500", "name": "Bag of gems" },
"amountCents": 499,
"currency": "EUR",
"country": "FR",
"vatRateBp": 2000,
"vatCents": 83,
"netCents": 416,
"createdAt": "2026-10-05T21:43:23.065Z",
"paidAt": null,
"fulfilledAt": null,
"refundedAt": null,
"creatorCode": null
},
"checkoutUrl": "https://gamecoin.apilow.com/pay/6ac419fbdbe02af6c99c3d47?t=…"
}
```
Open `checkoutUrl` in the player's browser: redirect the tab, or open a window. Do not store it or parse it. It works for one order, for one hour.
## The payment page
The page is hosted. It shows the pack, the contents, the price with VAT, a country selector, and the buttons. In test the buttons are « Payer » (pay) and « Refuser » (decline). The page is in French for now.
The buyer can change the country on the page. The VAT is recalculated before they pay, and the order is updated with the final country and VAT.
## VAT
Pack prices are in euros **with VAT included**. GameCoin works out the VAT from the buyer's country and writes it on the order, together with the amount without VAT:
| Order field | Meaning | Example (4.99 € in France) |
|---|---|---|
| `amountCents` | What the buyer pays, VAT included | `499` |
| `vatRateBp` | The rate in basis points: 2000 is 20 % | `2000` |
| `vatCents` | The VAT inside the price | `83` |
| `netCents` | The price without VAT | `416` |
The country is chosen in this order: the `country` of the checkout, then the `country` of the player's profile, then `BE`. A browser checkout cannot send a country, so set the player's country on your server, or let the buyer pick it on the payment page.
Packs are sold in the countries of the European Union: `AT`, `BE`, `BG`, `HR`, `CY`, `CZ`, `DK`, `EE`, `FI`, `FR`, `DE`, `GR`, `HU`, `IE`, `IT`, `LV`, `LT`, `LU`, `MT`, `NL`, `PL`, `PT`, `RO`, `SK`, `SI`, `ES` and `SE`. Any other country answers `400 VALIDATION_FAILED` with the field error `COUNTRY_NOT_SELLABLE`.
## Return URLs
`successUrl` and `cancelUrl` send the player back to your game.
- Use `https://` URLs. In the test environment, `http://` is accepted for the local machine only (`localhost`, `127.0.0.1`, `[::1]`). Any other `http://` answers `URL_SCHEME_NOT_ALLOWED`.
- A URL with a user name or password is refused (`URL_INVALID`).
- From a browser, they must have the same origin as the page that asks, so a script on another site cannot redirect your players. Otherwise: `ORIGIN_MISMATCH`.
After a successful payment, GameCoin redirects to `successUrl` and adds the order and its status to the query. After a declined payment it does the same with `cancelUrl`:
```text
https://example.com/shop/thanks?order=6ac419fbdbe02af6c99c3d47&status=fulfilled
https://example.com/shop?order=6ac41c5a62015d21b714ae0a&status=failed
```
If you give no URL, the player lands on a confirmation page of GameCoin, which also tells the window that opened it with a `postMessage`: `{ type: "gamecoin:order", orderId, status }`. The browser SDK listens for that message, so you need no URL at all with `gc.checkout`.
## What to do when the player comes back
**Never trust the address.** Anyone can type `?status=fulfilled`. The truth is the order, read from GameCoin:
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/orders/6ac419fbdbe02af6c99c3d47" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const order = await gamecoin.orders.get("6ac419fbdbe02af6c99c3d47");
console.log(order.status, order.paidAt, order.fulfilledAt); // fulfilled 2026-10-05T21:43:42.641Z 2026-10-05T21:43:42.641Z
```
```python tab="Python"
order = gamecoin.orders.get("6ac419fbdbe02af6c99c3d47")
print(order.status, order.paid_at, order.fulfilled_at) # fulfilled 2026-10-05 21:43:42.641000+00:00 2026-10-05 21:43:42.641000+00:00
```
```php tab="PHP"
$order = $gamecoin->orders->get('6ac419fbdbe02af6c99c3d47');
echo $order->status, ' ', $order->paidAt->format(DATE_RFC3339_EXTENDED), ' ', $order->fulfilledAt->format(DATE_RFC3339_EXTENDED), "\n"; // fulfilled 2026-10-05T21:43:42.641+00:00 2026-10-05T21:43:42.641+00:00
```
```go tab="Go"
order, err := client.Orders.Get(ctx, "6ac419fbdbe02af6c99c3d47")
if err != nil {
log.Fatal(err)
}
fmt.Println(order.Status, *order.PaidAt, *order.FulfilledAt) // fulfilled 2026-10-05 21:43:42.641 +0000 UTC 2026-10-05 21:43:42.641 +0000 UTC
```
```csharp tab="C#"
var order = await gamecoin.Orders.GetAsync("6ac419fbdbe02af6c99c3d47");
Console.WriteLine($"{order.Status} {order.PaidAt:O} {order.FulfilledAt:O}"); // fulfilled 2026-10-05T21:43:42.6410000+00:00 2026-10-05T21:43:42.6410000+00:00
```
```javascript tab="Browser"
// After a redirect checkout, init() reads the order for you and cleans the address.
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
if (gc.returnedOrder) console.log(gc.returnedOrder.status);
```
```json title="Answer"
{
"id": "6ac419fbdbe02af6c99c3d47",
"playerId": "6ac419fa60e877541898f38d",
"status": "fulfilled",
"pack": { "sku": "gems-500", "name": "Bag of gems" },
"amountCents": 499,
"currency": "EUR",
"country": "FR",
"vatRateBp": 2000,
"vatCents": 83,
"netCents": 416,
"createdAt": "2026-10-05T21:43:23.065Z",
"paidAt": "2026-10-05T21:43:42.641Z",
"fulfilledAt": "2026-10-05T21:43:42.641Z",
"refundedAt": null,
"creatorCode": null
}
```
Deliver nothing yourself. When the status is `fulfilled`, the coins and items are **already** in the player's account: the payment and the delivery happen together. A server only needs to read the order to update its screen. Read the wallet to see the result, and the ledger to see the entries (`purchase_credit`, with `ref.orderId`).
## The life of an order
```text
pending ─ paid ─▶ fulfilled ─▶ refunded
│
├────────────▶ failed the payment was declined
└────────────▶ expired nobody paid within one hour
```
| Status | Meaning | Final |
|---|---|---|
| `pending` | Created. Waiting for the payment. | no |
| `fulfilled` | Paid and delivered. | no: it can be refunded |
| `refunded` | Refunded: coins and items were taken back. | yes |
| `failed` | The payment was declined. Nothing was delivered. | yes |
| `expired` | Not paid within the hour. | yes |
(`paid` is a passing state that you will not see: payment and delivery are one step. `canceled` and `chargeback` exist in the API but nothing produces them yet.)
A `failed` or `expired` order is final. To let the player try again, create a **new** checkout with a **new idempotency key**. The same key would return the old order.
## Retry safely
A checkout needs an idempotency key. If your request times out, send it again with the same key: you get the same order and the same payment URL, with status `200` instead of `201`. The order is returned in its **current** state, so a replay after the player paid shows `fulfilled`.
Build the key from your own order or basket number (`order-1001`), not from the clock.
## Limits per player
A pack can have a `maxPerPlayer`, such as a welcome pack that each player may buy once. When the limit is reached, a new checkout answers `409 LIMIT_REACHED` with `maxPerPlayer` and `bought` in `details`. Only orders that were paid count: a pending, declined, expired or refunded order does not use up the limit.
## Limited and timed packs
A pack can be sold only during a **window**, in limited **stock**, or as a **founder pack**. A checkout then answers `409 PACK_NOT_AVAILABLE` outside the window and `409 PACK_SOLD_OUT` when the stock is used up; the catalog tells you in advance with `availability`. See [Founder packs](/docs/guides/founder-packs).
## Items inside a pack
A pack can include items. They are delivered with the currency. If one cannot be delivered, because the player already owns the most they can of a durable item, the pack is **still delivered**: the player gets the currency and the other items, and the item is marked as not delivered on the order. You see it in the order's page in the dashboard (« Objets non livrés »); the API's `Order` object does not list it. If a refund follows, only what was delivered is taken back.
## Where next
- [Founder packs](/docs/guides/founder-packs): sell before the launch, with a window and a limited stock.
- [Refunds](/docs/guides/refunds): take back a paid order.
- [Testing](/docs/guides/testing): pay and decline in the test environment.
- [Wallets and ledger](/docs/concepts/wallets-and-ledger)
---
# Founder packs
Source: https://gamecoin.apilow.com/docs/guides/founder-packs
> Sell packs before your game launches, during a window and in limited quantity, and read the stock left from the catalog.
## What you will do
A **founder pack** is an ordinary [pack](/docs/guides/selling-packs) with three extras: a **sale window**, a **total stock**, and a **founder** flag. You use it to sell before your game launches ("the first 500 players get a golden sword"), or to run a limited offer.
When a player buys one, the coins and items are delivered **right away** to the wallet of the player. There is no "pending" currency: the game reads the wallet when it launches, like any other balance. To let a player in a game without a backend keep that wallet, ask for an e-mail first: see [Sign in with e-mail](/docs/guides/player-email-sign-in).
The three options are independent. A pack can have only a window, only a stock, or only the flag. A pack with none of them is always on sale, without limit, as before.
## Set up a founder pack
In the dashboard (in French for now), open your game, then « Packs »:
1. Create or edit a pack. Open the section « Disponibilité et pack fondateur ».
2. Tick « Pack fondateur » to show the founder badge and a public counter.
3. Fill « Début de la vente » and « Fin de la vente » to set the window, and « Stock total » to limit the quantity. Leave a field empty for no limit. Dates are in Paris time.
4. Publish the pack. Each pack in the list shows its state (« Programmé », « En vente », « Épuisé », « Terminé »), the founder badge and the counter « vendus sur stock ».
To announce the launch, open « Réglages » and fill the section « Lancement »: tick « Le jeu n'est pas encore lancé (précommande) » and give the launch date. A shop then shows pre-orders, and the receipts mention the date.
Rules to know:
- The window starts at `startsAt` (included) and ends at `endsAt` (excluded).
- The total stock cannot be set below the number of units already sold.
- The sold counter of the dashboard counts the live environment.
## Read the availability
The [catalog](/docs/api/catalog#get-the-catalog) carries it, for the server and for the browser:
```bash
curl "https://gamecoin.apilow.com/api/v1/client/catalog" \
-H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY"
```
```json title="One pack of the answer"
{
"sku": "founder-pack",
"name": "Founder pack",
"priceCents": 1999,
"maxPerPlayer": 1,
"availability": {
"startsAt": "2026-10-01T00:00:00.000Z",
"endsAt": "2026-12-01T00:00:00.000Z",
"remaining": 312,
"founder": true
}
}
```
The answer also has `prelaunch` and `launchAt` for the whole game.
- `remaining` is the number of units that can still be bought, or `null` for an unlimited stock. Use it for the public counter: "312 left".
- The catalog also lists packs that are scheduled, ended or sold out. Show them as "coming soon", "ended" or "sold out" instead of hiding them. Compare `startsAt` and `endsAt` with the clock of the player.
- A pack that is not limited has `availability: null`.
## What happens when a player buys
An order for a limited pack **holds one unit of the stock** from the moment it is created. That is how GameCoin never sells more than the stock, even when thousands of players click at the same time.
| Moment | The unit is |
|---|---|
| The order is created (`pending`) | Held for the player, for up to one hour |
| The order is paid and delivered (`fulfilled`) | Sold |
| The order fails, expires or is canceled | Released: someone else can buy it |
| The order is refunded, or the buyer disputes the payment | Given back to the stock |
So `remaining` can go **up** again: when a pending order expires, or when you refund a sale. A second request by the same player for the same pack returns the order that is already pending and holds nothing more.
The window is checked when the order is created. A player who started before the end can still pay after it, within the hour.
## Errors
A checkout for a pack that cannot be sold answers `409`:
| Code | Meaning | What to show |
|---|---|---|
| `PACK_NOT_AVAILABLE` | Outside the window. `details.startsAt` and `details.endsAt` hold the dates. | "Coming soon" with the date, or "Ended" |
| `PACK_SOLD_OUT` | No unit left | "Sold out" |
```json
{ "error": { "code": "PACK_NOT_AVAILABLE", "message": "The pack is not on sale", "details": { "startsAt": "2026-11-01T09:00:00.000Z", "endsAt": null } } }
```
See [Errors](/docs/concepts/errors) for the full list.
## Test it
The test and live environments have **separate stocks**: a purchase made with a test key never uses a unit of the live stock. In the test environment you can run out of stock, wait for the orders to expire, and try the refunds, with simulated payments. See [Testing](/docs/guides/testing).
## Where next
- [Selling packs](/docs/guides/selling-packs): checkouts, VAT and the life of an order.
- [PackAvailability](/docs/api/objects#packavailability): the object in detail.
- [Refunds](/docs/guides/refunds): give a unit back to the stock.
---
# Gift codes and creator codes
Source: https://gamecoin.apilow.com/docs/guides/codes
> Hand out gift codes for free coins and items, and track the sales and the commission of your content creators with creator codes.
## What you will build
GameCoin has two kinds of codes. They look alike for a player, who types a short word, but they do very different jobs.
| | Gift code | Creator code |
|---|---|---|
| Who gets what | The player who types it gets **free coins and items**, once | The buyer gets a **bonus of free coins** on a purchase; the creator earns a **commission** |
| Where it is typed | In your game, when the player redeems it | At checkout, when the player buys a pack |
| Who makes it | You, in a **batch** of random codes (10 to 1,000 at a time) | You, one code per creator, with a name you choose (`LEAPLAY`) |
| Money | Never: free coins only, never paid coins | The commission is **tracked** in the dashboard; paying creators is not part of GameCoin yet |
Both live in the dashboard under **Codes** (in French for now), with one tab for each: **Cadeaux** (gifts) and **Créateurs** (creators). Like everything else, codes belong to an **environment**: a code created in `test` does not exist in `live`.
> [!NOTE]
> Codes are managed in the dashboard from the role **developer**. Members with the role **support** can see the numbers but not the codes themselves, which are worth coins.
## Gift codes
Use them for a contest, a convention, a partner or a streamer giveaway. Each code gives the same reward: some free coins, some items, or both.
### Create a batch
In the **Cadeaux** tab, choose **Nouveau lot** and fill in:
| Field | Meaning |
|---|---|
| Name | For you only (« Paris Games Week »). |
| Prefix | Optional, 2 to 8 letters or digits placed in front of every code (`PGW`), so a code tells where it came from. |
| Number of codes | From 1 to 1,000. Make another batch for more. |
| Uses per code | `1` for a single-use code; more for a code you share with a group. A player can still use a given code **only once**. |
| Valid until | Optional. The code works until the end of that day, Paris time. |
| What each code gives | A quantity of each free currency, and a quantity of each item. At least one. |
GameCoin generates the codes with ten random characters from an alphabet that has no look-alikes (no `0` and `O`, no `1`, `I` and `L`). With a prefix a code looks like `PGW-K7M2Q-XH4NP`. The dashes and the case do not matter when a player types it.
You can then open the batch to see every code, its state (active, used up, expired, disabled) and how often it was used, **export the codes as CSV** to print or send them, or **disable** a code or the whole batch. Disabling is final: the coins already given stay with the players, and you make a new batch to hand out codes again.
> [!WARNING]
> A code is worth its coins. Treat an exported file like a list of gift cards: send it only to the people who must have it, and disable a batch that leaked.
### Let a player redeem a code
The player types the code in your game, and your game calls one route.
From your **backend** (secret key), when you know who the player is:
```bash
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/codes/redeem" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: redeem-user-42-pgw" \
-H "Content-Type: application/json" \
-d '{"code": "pgw k7m2q xh4np"}'
```
From a **game without a backend** (publishable key and player token), with `POST /client/me/codes/redeem`: it is the same body and the same answer. See [Codes API](/docs/api/codes) for the details, the errors and a browser example.
The answer lists what was given: the coins with the new balance, and the items.
```json
{
"grants": [{ "currency": "gems", "amount": 100, "balance": { "currency": "gems", "paid": 0, "bonus": 100, "total": 100 } }],
"items": [{ "sku": "potion", "quantity": 2 }],
"skipped": []
}
```
### The rules that protect you
- **Free coins only.** A code credits the `bonus` bucket, which cannot be refunded in euros. The ledger shows the entry with the type `gift_code`.
- **Once per player, and a limited number of uses.** The count is exact even when a hundred players type the same code at the same moment: a single-use code goes to one player only.
- **Not transferable.** A code credits the player who types it. GameCoin has no way to pass coins from one player to another.
- **One answer for every refusal.** An unknown, expired, used-up, disabled or already used code all give `400 VALIDATION_FAILED` with `fieldErrors.code = ["CODE_INVALID"]`. This stops anyone from finding real codes by trying. Show one message: « This code is not valid ».
- **Limited attempts.** A player can try 10 codes per 15 minutes, right or wrong, and an IP address 30 (client API).
- **Safe to retry.** The call needs an `Idempotency-Key`. After a network error, send the same request with the same key: nothing is credited twice.
If a player already owns the most they can of a durable item, that item is skipped (the answer lists it in `skipped`) and the rest is still given. When the code has nothing left to give them, the call answers `409 LIMIT_REACHED` and the code is **not** used up.
## Creator codes
A creator code links a purchase to a content creator: a streamer, a YouTuber, a community. The creator shares the code; a buyer types it at checkout and gets extra free coins; you see the sales and the commission that the creator earned.
### Create a code
In the **Créateurs** tab, choose **Nouveau code créateur**:
| Field | Meaning |
|---|---|
| Code | 3 to 24 letters or digits, no accent (`LEAPLAY`). Players can type it in any case. It must be unique in the game. |
| Creator name | Shown on the buyer's receipt and in the order. |
| Buyer bonus | From 0 to 50 % of free coins on top of the pack. |
| Commission | From 0 to 30 % of the amount **excluding VAT**, tracked for the creator. |
| Creator's player | Optional. The player who is the creator in your game: they **cannot use their own code**. Enter `ext:`. |
| Packs | Optional. None ticked: the code works for every pack. |
You can change a code later (rates, name, packs) or disable it and enable it again. A change applies to the **next purchases**: an order keeps the bonus and the commission it had when the player started it.
### Use it at checkout
Pass the code the buyer typed as `creatorCode` when you create the order, with the server API or the client API:
```bash
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/checkouts" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: checkout-gems-500-user-42" \
-H "Content-Type: application/json" \
-d '{"packSku": "gems-500", "creatorCode": "leaplay"}'
```
The order answers with the code, and **never** with the rates:
```json
{
"order": {
"id": "665f1c2e8a3b4d5e6f7081b4",
"status": "pending",
"pack": { "sku": "gems-500", "name": "Bag of gems" },
"amountCents": 499,
"creatorCode": { "code": "LEAPLAY", "creatorName": "Léa Play" }
},
"checkoutUrl": "https://gamecoin.apilow.com/pay/665f1c2e8a3b4d5e6f7081b4?t=…"
}
```
(Only the useful fields are shown.) A code that cannot be used — unknown, disabled, not valid for this pack, or the creator's own player — is refused with `400 VALIDATION_FAILED` and `fieldErrors.creatorCode = ["CODE_INVALID"]`, the same answer in every case. The order is not created: tell the player the code is not valid and let them try again or buy without it. Order objects without a code have `creatorCode: null`. The code also appears in the `order.*` [webhooks](/docs/guides/webhooks), because they carry the same order object.
### What the buyer gets, what the creator earns
Both are computed **when the order is delivered**, from the pack frozen in the order, and both are rounded **down**.
| | Rule | Example: pack of 500 coins + 50 free, 4,99 € incl. VAT (4,12 € excl. VAT at 21 %), bonus 10 %, commission 10 % |
|---|---|---|
| Buyer bonus | `floor(coins bought × bonus % / 100)`, per currency, free coins | `floor(500 × 10 / 100)` = **50** free coins. The 50 free coins of the pack do not count. |
| Commission | `floor(net amount in cents × commission in basis points / 10,000)` | `floor(412 × 1000 / 10000)` = **41** cents |
The buyer's receipt shows the bonus on its own line, with the code. The coins arrive in the same ledger entry as the pack: 500 paid and 100 free.
**Refund and chargeback cancel everything.** When the order is refunded, or the player disputes the payment, the coins are taken back as usual, the bonus included, and the commission is **cancelled**: the creator's sheet counts the order as canceled and the commission due drops. Nothing is counted for an order that was never delivered.
### Follow the sales
Open a creator in the dashboard to see, for a period you choose (delivery dates):
- the number of orders, the sales including and excluding VAT, the **commission due**, and the bonus coins given to buyers;
- the orders that were canceled since, with the commission that was cancelled;
- the list of sales, and an **export as CSV** of the sales of the period (for your accounting, or to show a creator what they earned).
> [!NOTE]
> **GameCoin tracks the commission; it does not pay it.** The amount shown is what you owe the creator under your agreement. Paying creators automatically is planned for a later version: until then you pay them yourself.
## Test it
In the `test` environment, create a batch, redeem a code with a test player, then create a creator code and buy a pack with it: the payment is simulated. Check the wallet (`bonus` goes up), the ledger entry (`gift_code`), and the creator's sheet. See [Testing](/docs/guides/testing).
---
# The hosted shop
Source: https://gamecoin.apilow.com/docs/guides/hosted-shop
> Open a ready-made shop page for your game, identify the player with a signed link or an e-mail code, and bring them back to your game after a purchase.
## What you get
Every game can have a **hosted shop**: a public page at `/shop/` where a player sees their balance, buys packs, types a gift code or a creator code, and finds their latest purchases with the receipt. It is built for phones first, works in French, English and Dutch, and needs no code from you beyond a link.
Use it when your game runs on a phone, in an engine without a good checkout, or simply when you do not want to build a shop screen. The payment page, the VAT, the receipt and the withdrawal consent are the ones of GameCoin, as with any [pack purchase](/docs/guides/selling-packs).
What the player sees:
| Part | What it shows |
|---|---|
| Header | The shop name and tagline you chose, a link back to your game, the language switch (FR, EN, NL). |
| Balance | One card per currency, with the **paid** coins and the **free** coins told apart. |
| Code | A single field. A valid **gift code** is used at once and the balance updates. A **creator code** is kept for the next purchase and shown with its bonus. |
| Packs | Price in euros with VAT included, a badge (popular, best value, founder), the stock left and a countdown if you set an availability, and the states scheduled, sold out and ended. Before your launch the button says « pre-order ». |
| Purchases | The latest orders with their state and a link to the receipt. |
> [!NOTE]
> The shop is part of the **founder pack** and **codes** features: see [Founder packs](/docs/guides/founder-packs) and [Gift codes and creator codes](/docs/guides/codes). Anything you set there appears in the shop.
## Turn it on
In the dashboard (in French for now), open your game, then **Boutique**. Nothing is public until you tick **Activer la boutique**: until then the address answers « page not found », exactly as if it did not exist, whether the game is unknown, archived or has its shop switched off.
| Setting | Meaning |
|---|---|
| Activer la boutique | Turns the page on. |
| Nom affiché, Accroche | The name and the welcome sentence. The game name is used when the name is empty. |
| Couleur d'accent | One of eight colors. The contrast of the text on each color (white or dark, at least 4.5:1) is checked, so the shop stays readable. |
| URL de retour | Where the player goes back to after a purchase, in a web game. |
| Schémas d'application | Where the player goes back to in a phone app. |
| Indexation | Whether search engines may list the shop. Off by default. |
The page shows a live preview and a button that opens your shop in `test`. Changing the settings needs the role **developer**; the role **support** can read them.
## Open the shop from your game
The shop knows who the player is from a **player token**: the short token (one hour) your backend creates with `POST /players/{player}/tokens`, or the one the browser SDK holds for an anonymous player. Put it in the link:
```text
https:///shop/?t=&locale=en
```
Create the token on your server and build the link:
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/tokens" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: shop-link-user-42-1" \
-H "Content-Type: application/json" \
-d '{}'
```
```javascript tab="Node.js"
const { token } = await gamecoin.players.createToken("ext:user-42");
const link = `https:///shop/my-game?t=${encodeURIComponent(token)}&locale=en`;
```
Open the link in the browser of the player: a button, a `window.open`, the system browser of a phone, or an in-app web view.
What happens, so that the token never lingers:
1. The shop checks that the game has a shop and that the token is valid, unexpired and made for this game.
2. It swaps the token for a **shop session**: a signed cookie that is `HttpOnly`, `SameSite=Lax`, limited to the path `/shop/` and valid for one hour at most. The player is then sent to the shop **without the token in the address**.
3. The shop sends `Referrer-Policy: same-origin` and `X-Robots-Tag: noindex`, so the token is never sent as a `Referer` and the pages are not indexed.
The session is not an API token: it works only on the shop of that game. A link that is invalid, expired, made for another game, or for a player who is blocked, simply lands on the screen **Retrouve ton compte** with a notice. It never says which of these is the case.
### The environment
The player token carries the environment: a `test` token opens the shop in `test` (a banner says so, and the payments are simulated), a `live` token opens it in `live`. Nothing else in the address can switch to `live`.
### The language
The language is the one of the request: the `locale` parameter (`fr`, `en` or `nl`) when you give it, otherwise the `Accept-Language` header of the browser, otherwise French. Amounts and dates are written with the rules of that language.
## When the player has no link
Without a valid token the shop shows **Retrouve ton compte**: the player types an e-mail address, receives a six-digit code, and gets back the player that is linked to that address. It is the [e-mail sign-in](/docs/guides/player-email-sign-in) of the browser SDK, with the same rules: a code lasts ten minutes, five tries, and the answer is the same whether the address is known or not.
Two things to know:
- The address must already be **linked** to a player from your game. The shop does not create players.
- Signing in to the shop does **not** change the secret that the game keeps on the device: the player stays signed in in the game.
For a game in `test`, add `?env=test` to the address to open the screen in the test environment: `https:///shop/?env=test`. Only `test` is accepted from the address.
## Buy, pay and come back
A click on a pack creates the order (like `POST /client/me/checkouts`) and sends the player to the **hosted payment page**, where the country, the VAT and the withdrawal consent are confirmed. The shop asks for the return in both cases, success and cancel, so the player always comes back.
The return goes through `/shop//return`, which sends the player to **what you declared** in the settings, in this order:
1. **A phone app.** If the link of the shop carries `r=` and that scheme is in your **Schémas d'application**, the player is sent to `://shop/return?order=&status=`. Browsers on phones often block a redirect to an app scheme, so the player lands on a small page with a real button, **Retourner dans le jeu**, and the shop also tries to open the app by itself.
2. **A web page.** Otherwise, if you set an **URL de retour**, the player is redirected to it with `order` and `status` added.
3. **The shop.** Otherwise the player goes back to the shop, which reads the order and tells the result at the top: confirmed, being confirmed (the page refreshes by itself), declined, canceled or expired.
Add the app scheme to the link when your game is an app:
```text
https:///shop/?t=&r=mygame
```
The values are strict: `order` must be an order identifier and `status` one of the order states, otherwise the player is simply sent back to the shop. A scheme you did not declare is ignored, and the settings refuse `javascript:`, `data:`, `file:`, `http`, `https` and the other schemes that are not apps. The address of the return always comes from your settings, never from the request, so the shop cannot be used to redirect a player to another site. Your **URL de retour** must be `https` (`http` only for `localhost`, to test).
> [!WARNING]
> `status` in the return address is only a hint. Anyone can type it. Confirm the purchase from your backend with `GET /orders/{order}` or from the `order.fulfilled` [webhook](/docs/guides/webhooks) before you rely on it.
## Codes in the shop
The code field takes both kinds of code and **the server decides**:
- A **creator code** is kept for the next purchase (it lives in the shop session) and shown with its bonus, with the exact amount of free coins on each pack it covers. The player can remove it. It is checked again, with the pack, when the order is created.
- Otherwise the text is tried as a **gift code**: if it is valid it is used right away, the free coins and items are added and the balance updates. A gift code never gives paid coins.
A refused code always gets the same answer, whether it is unknown, expired, used up, disabled or already used by that player. The tries are limited per player and per address, whether they work or not. See [Gift codes and creator codes](/docs/guides/codes).
## Search engines
Shops are `noindex` by default. If you tick **Indexation**, only the page seen **without** signing in is open to search engines: a title and a description about your game, in the three languages, a canonical address, `hreflang` links, and a place in the sitemap for the games that are `live`. The page of a signed-in player (balance, purchases) is never indexed.
Each language has its own address, `https:///shop/?locale=fr`, `?locale=en` or `?locale=nl`.
## Embedded in a web page
The same page can run in an iframe, at `/shop//embed`: no header and no footer, a transparent background, a height that follows its content, a light or dark theme. The easy way is the **[shop widget](/docs/sdk/widget)**: one script, `GameCoinShop.open({ gameSlug, token })`, a modal that is accessible from the keyboard, and events for the balance and the purchases. It also explains how to add it to a GDevelop or Construct game.
What to know here:
- **Who can frame it.** Only the pages in the **allowed origins** of the game (« Réglages »). In `live` the list is mandatory; in `test` an empty list allows `http://localhost` and `http://127.0.0.1`. Every other page of GameCoin refuses to be framed.
- **The player.** An iframe of another site cannot keep a cookie, so the embedded shop swaps the player token (`?t=`) for a signed ticket of one hour that it sends back with each action. Without a valid token the shop says that the session has expired and tells the widget, which tells your game.
- **The payment.** It opens in its own window, since the payment page cannot be shown in an iframe; the shop follows the order by itself and announces the end.
- **The messages.** The shop talks to your page with `postMessage`: `gamecoin:ready`, `gamecoin:balance`, `gamecoin:purchased`, `gamecoin:height`, `gamecoin:close` (Escape inside the shop) and `gamecoin:error`. The target is always the exact origin of your page, and only if it is in the allowed origins; never `*`. Your page should use the widget rather than read them itself.
| You want to | Use |
|---|---|
| Send the player to a page of GameCoin and bring them back | The link of this page |
| Show the shop in a window over your web game, or inside a page | The [shop widget](/docs/sdk/widget) |
| Draw your own shop screen | The [browser SDK](/docs/sdk/browser) and your own buttons |
## Limits and good practice
- The shop session lasts one hour. After that the player opens the shop again from the game, or signs in with the e-mail code.
- A player sees only their own balance, orders and codes. The shop never shows an e-mail address.
- The tokens you put in links are short-lived: create one when the player taps the button, not in advance.
- Keep `test` and `live` apart: the same slug serves both, and the player token says which one.
---
# Refunds
Source: https://gamecoin.apilow.com/docs/guides/refunds
> Refund a paid order from your server, see what is taken back from the player, and read what a refund could not recover.
## What a refund does
A refund cancels one paid order. In one step, GameCoin:
1. gives the money back to the buyer (simulated in the test environment);
2. takes back the **currency** the order delivered, with one `refund_clawback` ledger entry per currency;
3. takes back the **items** the order delivered, as far as the player still owns them;
4. sets the order to `refunded`.
Only a **server** can refund, with the secret key. A browser cannot, and the SDK has no refund. A studio member can also refund from the order's page in the dashboard (« Rembourser la commande », in French).
## Refund an order
```endpoint
POST /orders/{orderId}/refund
```
The only field is an optional `reason`, up to 500 characters. An empty body means `{}`.
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/orders/6ac419fbdbe02af6c99c3d47/refund" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"reason":"customer request"}'
```
```javascript tab="Node.js"
const order = await gamecoin.orders.refund("6ac419fbdbe02af6c99c3d47", { reason: "customer request" });
console.log(order.status, order.refundedAt); // refunded 2026-10-05T21:44:07.009Z
```
```python tab="Python"
order = gamecoin.orders.refund("6ac419fbdbe02af6c99c3d47", reason="customer request")
print(order.status, order.refunded_at) # refunded 2026-10-05 21:44:07.009000+00:00
```
```php tab="PHP"
$order = $gamecoin->orders->refund('6ac419fbdbe02af6c99c3d47', ['reason' => 'customer request']);
echo $order->status, ' ', $order->refundedAt->format(DATE_RFC3339_EXTENDED), "\n"; // refunded 2026-10-05T21:44:07.009+00:00
```
```go tab="Go"
order, err := client.Orders.Refund(ctx, "6ac419fbdbe02af6c99c3d47", &gamecoin.RefundParams{Reason: "customer request"})
if err != nil {
log.Fatal(err)
}
fmt.Println(order.Status, *order.RefundedAt) // refunded 2026-10-05 21:44:07.009 +0000 UTC
```
```csharp tab="C#"
var order = await gamecoin.Orders.RefundAsync("6ac419fbdbe02af6c99c3d47", new RefundRequest { Reason = "customer request" });
Console.WriteLine($"{order.Status} {order.RefundedAt:O}"); // refunded 2026-10-05T21:44:07.0090000+00:00
```
```json title="Answer"
{
"id": "6ac419fbdbe02af6c99c3d47",
"playerId": "6ac419fa60e877541898f38d",
"status": "refunded",
"pack": { "sku": "gems-500", "name": "Bag of gems" },
"amountCents": 499,
"currency": "EUR",
"country": "FR",
"vatRateBp": 2000,
"vatCents": 83,
"netCents": 416,
"createdAt": "2026-10-05T21:43:23.065Z",
"paidAt": "2026-10-05T21:43:42.641Z",
"fulfilledAt": "2026-10-05T21:43:42.641Z",
"refundedAt": "2026-10-05T21:44:07.009Z",
"creatorCode": null
}
```
Only a `fulfilled` order can be refunded. Any other state answers `409 CONFLICT`: an order still `pending` was never paid, and a `refunded` one is already done. A refund cannot be done twice, so a second call changes nothing.
An unknown order id answers `404 NOT_FOUND`. The `reason` is kept with the order and is visible in the dashboard.
### If the call fails halfway
A refund has no idempotency key. It is protected by the state of the order instead. After a network error or a `5xx`, **read the order before you try again**:
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/orders/6ac419fbdbe02af6c99c3d47" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const order = await gamecoin.orders.get("6ac419fbdbe02af6c99c3d47");
if (order.status === "fulfilled") {
await gamecoin.orders.refund(order.id, { reason: "customer request" }); // still to do
}
```
```python tab="Python"
order = gamecoin.orders.get("6ac419fbdbe02af6c99c3d47")
if order.status == "fulfilled":
gamecoin.orders.refund(order.id, reason="customer request") # still to do
```
```php tab="PHP"
$order = $gamecoin->orders->get('6ac419fbdbe02af6c99c3d47');
if ($order->status === 'fulfilled') {
$gamecoin->orders->refund($order->id, ['reason' => 'customer request']); // still to do
}
```
```go tab="Go"
order, err := client.Orders.Get(ctx, "6ac419fbdbe02af6c99c3d47")
if err != nil {
log.Fatal(err)
}
if order.Status == gamecoin.OrderFulfilled {
// still to do
if _, err := client.Orders.Refund(ctx, order.ID, &gamecoin.RefundParams{Reason: "customer request"}); err != nil {
log.Fatal(err)
}
}
```
```csharp tab="C#"
var order = await gamecoin.Orders.GetAsync("6ac419fbdbe02af6c99c3d47");
if (order.Status == "fulfilled")
{
await gamecoin.Orders.RefundAsync(order.Id, new RefundRequest { Reason = "customer request" }); // still to do
}
```
If the status is `refunded`, the refund went through. If it is still `fulfilled`, send it again.
## What is taken back
The refund takes back **what the order delivered**, not what the player has today. The order remembers, for each currency, how many `paid` and `bonus` coins it credited. The pack `gems-500` credited 500 `paid` and 50 `bonus` gems, so a refund takes back 550.
If the pack included items, the refund removes them, **up to what the player still owns**. A player who already drank the potion of a starter pack has none left to remove: the refund removes nothing more, and the quantity never goes below zero.
## When the player already spent the coins
The order credited 550 gems. If the player has spent some, the wallet cannot give 550 back. What happens depends on a setting of your game (« Après un remboursement » in the dashboard, which is in French):
| Setting | Dashboard name | What the refund does |
|---|---|---|
| **Allow a debt** (default) | « Autoriser une dette » | Takes back all 550. The balance goes below zero: the player is in debt. |
| **Stop at zero** | « S'arrêter à zéro » | Takes back only what is left. The balance never goes below zero. The rest is a **shortfall**, and you bear it. |
With the default, a player who bought `gems-500`, spent 400, and was refunded, ends at **-400**:
```json title="The wallet after the refund"
{
"playerId": "6ac419fa60e877541898f38d",
"balances": [
{ "currency": "gems", "paid": -400, "bonus": 0, "total": -400 },
{ "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
]
}
```
A debt behaves like this:
- The player **cannot spend** until the debt is paid: a spend answers `INSUFFICIENT_FUNDS` with `available: 0`.
- Every credit **pays the debt first**. If you grant 100 gems to this player, the balance becomes -300, not 100. A pack they buy pays the debt first too.
- The balance is never negative in `bonus`; the debt sits in `paid`.
The two settings are a choice between two risks. A debt protects you from a player who buys, spends, then asks for a refund. Stopping at zero never shows a negative balance, but the coins that player spent are your loss.
## Read what was taken, and what was not
Every refund writes `refund_clawback` entries in the ledger, one per currency, each with the `ref.orderId` of the order. The order's own `purchase_credit` entries have the same `ref.orderId` and tell what was delivered. Compare the two:
```bash tab="curl"
# The ledger entries of the player: keep those whose ref.orderId is the order,
# then add up purchase_credit (delivered) and refund_clawback (taken back) for each currency.
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?limit=100" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
// What did this refund take back, and what is missing?
async function readRefund(player, order) {
const currencies = {};
for await (const entry of gamecoin.ledger.iterate(player, { limit: 100 })) {
if (entry.ref.orderId !== order.id) continue;
const line = (currencies[entry.currency] ??= { credited: 0, taken: 0 });
const change = entry.paidDelta + entry.bonusDelta;
if (entry.type === "purchase_credit") line.credited += change;
if (entry.type === "refund_clawback") line.taken -= change;
}
for (const line of Object.values(currencies)) line.shortfall = line.credited - line.taken;
const wallet = await gamecoin.wallet.get(player);
const debts = wallet.balances.filter((b) => b.total < 0).map((b) => `${b.currency}: ${-b.total}`);
return { currencies, debts };
}
console.log(await readRefund(ext("user-42"), order));
// { currencies: { gems: { credited: 550, taken: 550, shortfall: 0 } }, debts: [ 'gems: 400' ] }
```
```python tab="Python"
# What did this refund take back, and what is missing?
def read_refund(player, order):
currencies = {}
for entry in gamecoin.ledger.iterate(player, limit=100):
if entry.ref.order_id != order.id:
continue
line = currencies.setdefault(entry.currency, {"credited": 0, "taken": 0})
change = entry.paid_delta + entry.bonus_delta
if entry.type == "purchase_credit":
line["credited"] += change
if entry.type == "refund_clawback":
line["taken"] -= change
for line in currencies.values():
line["shortfall"] = line["credited"] - line["taken"]
wallet = gamecoin.wallet.get(player)
debts = [f"{b.currency}: {-b.total}" for b in wallet.balances if b.total < 0]
return {"currencies": currencies, "debts": debts}
print(read_refund(ext("user-42"), order))
# {'currencies': {'gems': {'credited': 550, 'taken': 550, 'shortfall': 0}}, 'debts': ['gems: 400']}
```
```php tab="PHP"
// What did this refund take back, and what is missing?
$readRefund = function ($player, $order) use ($gamecoin): array {
$currencies = [];
foreach ($gamecoin->ledger->iterate($player, ['limit' => 100]) as $entry) {
if ($entry->ref->orderId !== $order->id) {
continue;
}
$currencies[$entry->currency] ??= ['credited' => 0, 'taken' => 0];
$change = $entry->paidDelta + $entry->bonusDelta;
if ($entry->type === 'purchase_credit') {
$currencies[$entry->currency]['credited'] += $change;
}
if ($entry->type === 'refund_clawback') {
$currencies[$entry->currency]['taken'] -= $change;
}
}
foreach ($currencies as &$line) {
$line['shortfall'] = $line['credited'] - $line['taken'];
}
unset($line);
$wallet = $gamecoin->wallet->get($player);
$debts = [];
foreach ($wallet->balances as $balance) {
if ($balance->total < 0) {
$debts[] = "{$balance->currency}: " . -$balance->total;
}
}
return ['currencies' => $currencies, 'debts' => $debts];
};
echo json_encode($readRefund(PlayerRef::ext('user-42'), $order)), "\n";
// {"currencies":{"gems":{"credited":550,"taken":550,"shortfall":0}},"debts":["gems: 400"]}
```
```go tab="Go"
// What did this refund take back, and what is missing?
type refundLine struct{ Credited, Taken, Shortfall int64 }
readRefund := func(player gamecoin.PlayerRef, order *gamecoin.Order) (map[string]*refundLine, []string, error) {
currencies := map[string]*refundLine{}
it := client.Ledger.Iterate(ctx, player, &gamecoin.LedgerListParams{Limit: 100})
for it.Next() {
entry := it.Entry()
if entry.Ref.OrderID == nil || *entry.Ref.OrderID != order.ID {
continue
}
line, ok := currencies[entry.Currency]
if !ok {
line = &refundLine{}
currencies[entry.Currency] = line
}
change := entry.PaidDelta + entry.BonusDelta
if entry.Type == gamecoin.LedgerPurchaseCredit {
line.Credited += change
}
if entry.Type == gamecoin.LedgerRefundClawback {
line.Taken -= change
}
}
if err := it.Err(); err != nil {
return nil, nil, err
}
for _, line := range currencies {
line.Shortfall = line.Credited - line.Taken
}
wallet, err := client.Wallet.Get(ctx, player)
if err != nil {
return nil, nil, err
}
var debts []string
for _, b := range wallet.Balances {
if b.Total < 0 {
debts = append(debts, fmt.Sprintf("%s: %d", b.Currency, -b.Total))
}
}
return currencies, debts, nil
}
currencies, debts, err := readRefund(gamecoin.Ext("user-42"), order)
if err != nil {
log.Fatal(err)
}
fmt.Println(*currencies["gems"], debts)
// {550 550 0} [gems: 400]
```
```csharp tab="C#"
using System.Text.Json;
// What did this refund take back, and what is missing?
async Task