# 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 ReadRefundAsync(PlayerRef player, Order order) { var currencies = new Dictionary>(); await foreach (var entry in gamecoin.Ledger.IterateAsync(player, new LedgerQuery { Limit = 100 })) { if (entry.Ref.OrderId != order.Id) continue; if (!currencies.TryGetValue(entry.Currency, out var line)) { currencies[entry.Currency] = line = new Dictionary { ["credited"] = 0, ["taken"] = 0 }; } var change = entry.PaidDelta + entry.BonusDelta; if (entry.Type == LedgerEntryType.PurchaseCredit) line["credited"] += change; if (entry.Type == LedgerEntryType.RefundClawback) line["taken"] -= change; } foreach (var line in currencies.Values) line["shortfall"] = line["credited"] - line["taken"]; var wallet = await gamecoin.Wallet.GetAsync(player); var debts = wallet.Balances.Where(b => b.Total < 0).Select(b => $"{b.Currency}: {-b.Total}"); return new { currencies, debts }; } Console.WriteLine(JsonSerializer.Serialize(await ReadRefundAsync(PlayerRef.Ext("user-42"), order))); // {"currencies":{"gems":{"credited":550,"taken":550,"shortfall":0}},"debts":["gems: 400"]} ``` - With **allow a debt**, `shortfall` is always `0`: everything was taken. Read the debt in the wallet, as a negative `total`. - With **stop at zero**, `taken` is smaller than `credited`. The difference is the shortfall. In the dashboard, the order's page shows the shortfall (« Manque à la reprise »), the debt, and the entries the order caused (« Écritures liées »). The API does not return a `shortfall` field: use the comparison above. ## Refund and the ledger together Nothing is deleted. After a refund, the ledger of the player holds the purchase, the spends, and the clawback. Their sum is the balance, debt included. | Entry | `paidDelta` | `bonusDelta` | `balanceAfter.total` | |---|---|---|---| | `purchase_credit` | +500 | +50 | 550 | | `spend` (400 gems) | −350 | −50 | 150 | | `refund_clawback` | −550 | 0 | −400 | ## Where next - [Wallets and ledger](/docs/concepts/wallets-and-ledger): the rules of buckets and debts. - [Selling packs](/docs/guides/selling-packs): the other end of an order. - [Testing](/docs/guides/testing): try a refund in the test environment. --- # Webhooks Source: https://gamecoin.apilow.com/docs/guides/webhooks > Let GameCoin call your server when an order is delivered, refunded or disputed, and check the signature of every call. ## What a webhook does A webhook is a call that GameCoin makes **to your server**. You do not poll the API to find out that a player paid: when an order changes state, GameCoin sends an event to the address you declared, signed with a secret that only you and GameCoin know. Use webhooks to credit what the game keeps on its own side (an unlock, a counter, an achievement), to refresh a wallet shown on screen, or to react to a refund. > [!NOTE] > The wallet and the inventory are already updated by GameCoin when the event reaches you. A webhook is a notification, not a request to credit: never grant coins because an event says so without first checking its signature. ## Declare an endpoint In the dashboard (in French for now), open your game, then « Webhooks ». For each environment you can add an endpoint: | Field | Rule | |---|---| | Address | `https` only. `http` is accepted towards `localhost` in the test environment, to develop at home. Addresses of private networks, `localhost` in live and internal names are refused. | | Events | At least one of the events below. | | State | Active or not. An inactive endpoint receives nothing. | A game has **five endpoints at most**, both environments together. Only members with the developer role or above can create, change or delete an endpoint. When you create an endpoint, the dashboard shows its **signing secret** (`whsec_gc_…`) once. Copy it into your server's configuration, never into the game. If you lose it, generate a new one with « Nouveau secret »: the previous secret stops working at once. ## Events | Event | When | |---|---| | `order.fulfilled` | An order is paid and delivered: the currency and the items are credited. | | `wallet.credited` | The same delivery, when it added currency to a wallet. It follows `order.fulfilled`. | | `order.refunded` | An order is refunded, from your server, from the dashboard or from the payment provider. Its coins and items are taken back. | | `order.chargeback` | The payer disputes the payment. Its coins and items are taken back. | The test environment sends the events of simulated orders, the live environment those of real payments. An endpoint only receives the events of its own environment. ## The request GameCoin sends a `POST` with a JSON body and these headers: | Header | Value | |---|---| | `Content-Type` | `application/json; charset=utf-8` | | `GameCoin-Signature` | `t=,v1=`, see [below](#verify-the-signature) | | `GameCoin-Event-Id` | The id of the event, the same as `id` in the body. | | `GameCoin-Event-Type` | The type of the event. | Every body has the same envelope: ```json title="order.fulfilled" { "id": "evt_6ac419fbdbe02af6c99c3d47_ful", "type": "order.fulfilled", "createdAt": "2026-10-06T10:00:02.000Z", "env": "test", "data": { "id": "6ac419fbdbe02af6c99c3d47", "playerId": "6ac419fa60e877541898f38d", "status": "fulfilled", "pack": { "sku": "gems-500", "name": "Bag of gems" }, "amountCents": 499, "currency": "EUR", "country": "BE", "vatRateBp": 2100, "vatCents": 87, "netCents": 412, "createdAt": "2026-10-06T10:00:00.000Z", "paidAt": "2026-10-06T10:00:01.000Z", "fulfilledAt": "2026-10-06T10:00:01.000Z", "refundedAt": null, "creatorCode": null } } ``` For `order.*` events, `data` is the [Order object](/docs/api/objects) of the API, exactly what `GET /orders/{orderId}` returns. For `wallet.credited`, `data` says which player received what: ```json title="wallet.credited" { "id": "evt_6ac419fbdbe02af6c99c3d47_cre", "type": "wallet.credited", "createdAt": "2026-10-06T10:00:02.000Z", "env": "test", "data": { "playerId": "6ac419fa60e877541898f38d", "orderId": "6ac419fbdbe02af6c99c3d47", "credits": [{ "currency": "gems", "paid": 500, "bonus": 50 }] } } ``` The `id` of an order event is always the same for the same event: use it to process an event **once**, because GameCoin can send it again (see [Delivery and retries](#delivery-and-retries)). ## Verify the signature The signature proves that GameCoin sent the call and that nobody changed the body. It is the HMAC-SHA256 of the text `.`, with your signing secret as the key, written in lowercase hexadecimal. It works like the signature of Stripe's webhooks. To verify a call: 1. Read the **raw** body, before any JSON parsing: a parsed and re-serialized body can differ by one character. 2. Read `t` and every `v1` from the `GameCoin-Signature` header. 3. Refuse the call if `t` is more than 5 minutes away from your clock: it protects you from a replayed call. 4. Compute the signature of `t + "." + body` and compare it with each `v1`, in constant time. ```javascript tab="Node.js" import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyGameCoinSignature(rawBody, header, secret, toleranceSeconds = 300) { let timestamp = null; const signatures = []; for (const part of header.split(",")) { const [key, value] = part.trim().split("="); if (key === "t") timestamp = Number(value); if (key === "v1" && value) signatures.push(value); } if (!Number.isInteger(timestamp) || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest(); return signatures.some((hex) => { const given = Buffer.from(hex, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); }); } ``` ```python tab="Python" import hashlib import hmac import time def verify_gamecoin_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: timestamp = None signatures = [] for part in header.split(","): key, _, value = part.strip().partition("=") if key == "t" and value.isdigit(): timestamp = int(value) elif key == "v1" and value: signatures.append(value) if timestamp is None or not signatures or abs(time.time() - timestamp) > tolerance: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, given) for given in signatures) ``` ```php tab="PHP" function verifyGameCoinSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't' && ctype_digit($value)) { $timestamp = (int) $value; } elseif ($key === 'v1' && $value !== '') { $signatures[] = $value; } } if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > $tolerance) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $given) { if (hash_equals($expected, $given)) { return true; } } return false; } // $rawBody = file_get_contents('php://input'); // $header = $_SERVER['HTTP_GAMECOIN_SIGNATURE'] ?? ''; ``` ```go tab="Go" package gamecoinhook import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "strconv" "strings" "time" ) // Verify checks the GameCoin-Signature header of a webhook call. rawBody is the body exactly as received. func Verify(rawBody []byte, header, secret string, tolerance time.Duration) bool { var timestamp int64 var signatures []string for _, part := range strings.Split(header, ",") { key, value, _ := strings.Cut(strings.TrimSpace(part), "=") switch key { case "t": if n, err := strconv.ParseInt(value, 10, 64); err == nil { timestamp = n } case "v1": if value != "" { signatures = append(signatures, value) } } } if timestamp == 0 || len(signatures) == 0 { return false } age := time.Since(time.Unix(timestamp, 0)) if age < 0 { age = -age } if age > tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(mac, "%d.", timestamp) mac.Write(rawBody) expected := mac.Sum(nil) for _, candidate := range signatures { given, err := hex.DecodeString(candidate) if err == nil && hmac.Equal(given, expected) { return true } } return false } ``` ```csharp tab="C#" using System.Security.Cryptography; using System.Text; public static class GameCoinWebhook { public static bool Verify(byte[] rawBody, string header, string secret, int toleranceSeconds = 300) { long? timestamp = null; var signatures = new List(); foreach (var part in header.Split(',')) { var pair = part.Trim().Split('=', 2); if (pair.Length != 2) continue; if (pair[0] == "t" && long.TryParse(pair[1], out var t)) timestamp = t; else if (pair[0] == "v1" && pair[1].Length > 0) signatures.Add(pair[1]); } if (timestamp is null || signatures.Count == 0) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp.Value) > toleranceSeconds) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var signed = Encoding.UTF8.GetBytes($"{timestamp}.").Concat(rawBody).ToArray(); var expected = hmac.ComputeHash(signed); foreach (var candidate in signatures) { try { if (CryptographicOperations.FixedTimeEquals(Convert.FromHexString(candidate), expected)) return true; } catch (FormatException) { // Not hexadecimal: this value cannot match. } } return false; } } ``` > [!WARNING] > Verify the signature on the **raw** body. With Express, read the body with `express.raw({ type: "application/json" })` on this route, not `express.json()`. Compare signatures in constant time, as above, and keep the secret out of your logs. Here is a complete route in Node.js with Express. It answers `200` once the event is recorded, and `400` when the signature is wrong: ```javascript title="server.js" import express from "express"; import { verifyGameCoinSignature } from "./verify.js"; const app = express(); app.post("/gamecoin/webhook", express.raw({ type: "application/json" }), async (req, res) => { const header = req.get("GameCoin-Signature") ?? ""; if (!verifyGameCoinSignature(req.body, header, process.env.GAMECOIN_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString("utf8")); if (await alreadyProcessed(event.id)) return res.sendStatus(200); // a repeated event: nothing to do if (event.type === "order.fulfilled") { await unlockPurchase(event.data.playerId, event.data.pack.sku); } await markProcessed(event.id); res.sendStatus(200); }); ``` ## Delivery and retries - **At least once.** An event is written in the same step as the change of the order: if the order changed, the event exists, and if the change was cancelled, no event is sent. Because a call can be repeated, your handler must tolerate the same `id` twice. - **Success is a `2xx`** answer within **10 seconds**. Answer quickly, then do the heavy work after. Redirections are not followed: a `3xx` is a failure. - **Retries.** After a failure GameCoin tries again 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours later, then gives up on that event. The event stays in the log and you can send it again by hand. - **Order.** Events are sent as soon as possible, in the order they were written, but a retry can make an older event arrive after a newer one. Do not rely on the order: `data.status` and the dates of the order tell you the current state. - **A failing endpoint is disabled.** After **50 failures in a row** the endpoint is disabled and the dashboard warns you. Fix your server, send a test, then enable the endpoint again. - **Secure destinations only.** GameCoin refuses to call a private or internal address, and checks the address that your domain name resolves to each time it sends. The delivery log of each endpoint shows, for every event, the HTTP status, the duration, the number of attempts and the beginning of your answer. From there a developer can send an event again (the same `id`) or send a `webhook.test` event to check the signature code. > [!TIP] > The `webhook.test` event has the same envelope and the same signature as the others, with `"type": "webhook.test"`. Answer it like any other event. ## Allowed origins A related setting lives in « Réglages »: the **allowed origins** of a game. They are the web pages of your game (`https://yourgame.example.com`) that may call the [client API](/docs/api/client) from a browser, and where the player is sent back after a payment. In the test environment an empty list accepts every origin; in the live environment an empty list refuses every browser. --- # Free currencies and items Source: https://gamecoin.apilow.com/docs/guides/free-currencies-and-items > Hand out currency and items from your server, let players buy and use items, and know the limits that apply. ## What you will do Not everything is sold for euros. Players also earn coins by playing, and spend them on items. This guide covers: - granting currency from your server (daily rewards, quests, prizes); - granting an item; - buying an item with a currency; - consuming an item; - the limits that cap each of them. The examples use a free currency `gold`, the paid currency `gems`, a consumable item `potion` (20 gems) and a durable item `fire-sword` (300 gems, one at most). Set up in the dashboard: [A game without a backend](/docs/guides/no-backend-game#before-you-start). A free currency is « Gratuite » in the dashboard; a paid one is « Payante ». ## Grant currency Only your server grants (or the dashboard, for a manual gift). A grant always credits the free `bonus` bucket, for a free currency and for a paid one. ```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-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 { balance } = await gamecoin.wallet.grant( ext("user-42"), { currency: "gold", amount: 50, reason: "daily_reward", metadata: { day: "3" } }, { idempotencyKey: "daily-user-42-2026-10-05" }, ); console.log(balance); // { currency: 'gold', paid: 0, bonus: 50, total: 50 } ``` ```python tab="Python" movement = gamecoin.wallet.grant( ext("user-42"), currency="gold", amount=50, reason="daily_reward", metadata={"day": "3"}, idempotency_key="daily-user-42-2026-10-05", ) print(movement.balance) # Balance(currency='gold', paid=0, bonus=50, total=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-user-42-2026-10-05'], ); echo json_encode($movement->balance), "\n"; // {"currency":"gold","paid":0,"bonus":50,"total":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-user-42-2026-10-05"), ) if err != nil { log.Fatal(err) } fmt.Printf("%+v\n", movement.Balance) // {Currency:gold Paid:0 Bonus:50 Total: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-user-42-2026-10-05" }); Console.WriteLine(movement.Balance); // Balance { Currency = gold, Paid = 0, Bonus = 50, Total = 50 } ``` `reason` and `metadata` come back in the ledger, so use them to say why: a quest id, a match id, a day. The **idempotency key** is what makes a daily reward daily: the key `daily-user-42-2026-10-05` can be sent as often as you like and pays once. See [Idempotency](/docs/concepts/idempotency). ## Give an item An item can also be given. This adds units to the player's inventory. ```endpoint POST /players/{player}/inventory/grant ``` ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/inventory/grant" \ -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \ -H "Idempotency-Key: welcome-potions-user-42" \ -H "Content-Type: application/json" \ -d '{"sku":"potion","quantity":3}' ``` ```javascript tab="Node.js" const potions = await gamecoin.inventory.grant( ext("user-42"), { sku: "potion", quantity: 3 }, { idempotencyKey: "welcome-potions-user-42" }, ); console.log(potions.quantity); // 3 ``` ```python tab="Python" potions = gamecoin.inventory.grant( ext("user-42"), sku="potion", quantity=3, idempotency_key="welcome-potions-user-42", ) print(potions.quantity) # 3 ``` ```php tab="PHP" $potions = $gamecoin->inventory->grant( PlayerRef::ext('user-42'), ['sku' => 'potion', 'quantity' => 3], ['idempotency_key' => 'welcome-potions-user-42'], ); echo $potions->quantity, "\n"; // 3 ``` ```go tab="Go" potions, err := client.Inventory.Grant( ctx, gamecoin.Ext("user-42"), gamecoin.InventoryParams{SKU: "potion", Quantity: 3}, gamecoin.WithIdempotencyKey("welcome-potions-user-42"), ) if err != nil { log.Fatal(err) } fmt.Println(potions.Quantity) // 3 ``` ```csharp tab="C#" var potions = await gamecoin.Inventory.GrantAsync( PlayerRef.Ext("user-42"), new InventoryGrantRequest { Sku = "potion", Quantity = 3 }, new RequestOptions { IdempotencyKey = "welcome-potions-user-42" }); Console.WriteLine(potions.Quantity); // 3 ``` ```json title="Answer" { "item": { "sku": "potion", "quantity": 3, "updatedAt": "2026-10-05T21:55:10.200Z" } } ``` `quantity` is optional and defaults to 1. The answer is the item as the player now holds it: `quantity` is the **total**, not the amount added. ## Buy an item with a currency An item with a price is bought with its own currency. The wallet is debited and the item added in **one step**: either both happen or neither does. In the examples, a player with 1000 gems and no potion buys two potions. ```endpoint POST /players/{player}/purchases ``` ```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: potions-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: "potions-user-42-order-9" }, ); console.log(purchase.balance.total, purchase.item.quantity, purchase.entry.ref.itemSku); // 960 2 potion ``` ```python tab="Python" purchase = gamecoin.purchases.create( ext("user-42"), sku="potion", quantity=2, idempotency_key="potions-user-42-order-9", ) print(purchase.balance.total, purchase.item.quantity, purchase.entry.ref.item_sku) # 960 2 potion ``` ```php tab="PHP" $purchase = $gamecoin->purchases->create( PlayerRef::ext('user-42'), ['sku' => 'potion', 'quantity' => 2], ['idempotency_key' => 'potions-user-42-order-9'], ); echo $purchase->balance->total, ' ', $purchase->item->quantity, ' ', $purchase->entry->ref->itemSku, "\n"; // 960 2 potion ``` ```go tab="Go" purchase, err := client.Purchases.Create( ctx, gamecoin.Ext("user-42"), gamecoin.PurchaseParams{SKU: "potion", Quantity: 2}, gamecoin.WithIdempotencyKey("potions-user-42-order-9"), ) if err != nil { log.Fatal(err) } fmt.Println(purchase.Balance.Total, purchase.Item.Quantity, *purchase.Entry.Ref.ItemSKU) // 960 2 potion ``` ```csharp tab="C#" var purchase = await gamecoin.Purchases.CreateAsync( PlayerRef.Ext("user-42"), new PurchaseRequest { Sku = "potion", Quantity = 2 }, new RequestOptions { IdempotencyKey = "potions-user-42-order-9" }); Console.WriteLine($"{purchase.Balance.Total} {purchase.Item.Quantity} {purchase.Entry.Ref.ItemSku}"); // 960 2 potion ``` ```javascript tab="Browser" // Only items marked « Achetable depuis le jeu (API cliente) » can be bought from the browser. const { balance, item } = await gc.buyItem("potion", 2); console.log(balance.total, item.quantity); ``` ```json title="Answer to the server call" { "entry": { "id": "6ac419ee0b7ba2060d7ef399", "type": "spend", "currency": "gems", "paidDelta": 0, "bonusDelta": -40, "balanceAfter": { "paid": 0, "bonus": 960, "total": 960 }, "reason": null, "metadata": {}, "ref": { "itemSku": "potion" }, "createdAt": "2026-10-05T21:43:10.807Z" }, "balance": { "currency": "gems", "paid": 0, "bonus": 960, "total": 960 }, "item": { "sku": "potion", "quantity": 2, "updatedAt": "2026-10-05T21:43:10.871Z" } } ``` The purchase is a `spend` entry whose `ref.itemSku` names the item. It follows the same rules as every spend: free coins first, and `409 INSUFFICIENT_FUNDS` if the player cannot afford it. An item with no price (`price: null`) cannot be bought with a currency: give it, or sell it inside a pack. ## Consume an item A consumable item is used up. This removes units from the inventory. In the examples, the player who just bought two potions drinks one. ```endpoint POST /players/{player}/inventory/consume ``` ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/inventory/consume" \ -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \ -H "Idempotency-Key: drink-user-42-1" \ -H "Content-Type: application/json" \ -d '{"sku":"potion"}' ``` ```javascript tab="Node.js" const used = await gamecoin.inventory.consume(ext("user-42"), { sku: "potion" }, { idempotencyKey: "drink-user-42-1" }); console.log(used.quantity); // 1 ``` ```python tab="Python" used = gamecoin.inventory.consume(ext("user-42"), sku="potion", idempotency_key="drink-user-42-1") print(used.quantity) # 1 ``` ```php tab="PHP" $used = $gamecoin->inventory->consume(PlayerRef::ext('user-42'), ['sku' => 'potion'], ['idempotency_key' => 'drink-user-42-1']); echo $used->quantity, "\n"; // 1 ``` ```go tab="Go" used, err := client.Inventory.Consume(ctx, gamecoin.Ext("user-42"), gamecoin.InventoryParams{SKU: "potion"}, gamecoin.WithIdempotencyKey("drink-user-42-1")) if err != nil { log.Fatal(err) } fmt.Println(used.Quantity) // 1 ``` ```csharp tab="C#" var used = await gamecoin.Inventory.ConsumeAsync(PlayerRef.Ext("user-42"), new InventoryConsumeRequest { Sku = "potion" }, new RequestOptions { IdempotencyKey = "drink-user-42-1" }); Console.WriteLine(used.Quantity); // 1 ``` ```javascript tab="Browser" await gc.consume("potion"); // the "change" event fires with the new quantity console.log(gc.owned("potion")); ``` The answer carries the item as it is now, **even when it reaches 0**. Reading the inventory lists only items the player owns (quantity above 0). Here it is the inventory of a player who owns a fire-sword and one potion: ```bash tab="curl" curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/inventory" \ -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" ``` ```javascript tab="Node.js" const items = await gamecoin.inventory.list(ext("user-42")); console.log(items.map((item) => `${item.sku} x${item.quantity}`)); // [ 'fire-sword x1', 'potion x1' ] ``` ```python tab="Python" items = gamecoin.inventory.list(ext("user-42")) print([f"{item.sku} x{item.quantity}" for item in items]) # ['fire-sword x1', 'potion x1'] ``` ```php tab="PHP" $items = $gamecoin->inventory->list(PlayerRef::ext('user-42')); echo json_encode(array_map(static fn ($item) => "{$item->sku} x{$item->quantity}", $items)), "\n"; // ["fire-sword x1","potion x1"] ``` ```go tab="Go" items, err := client.Inventory.List(ctx, gamecoin.Ext("user-42")) if err != nil { log.Fatal(err) } for _, item := range items { fmt.Printf("%s x%d\n", item.SKU, item.Quantity) } // fire-sword x1 // potion x1 ``` ```csharp tab="C#" var items = await gamecoin.Inventory.ListAsync(PlayerRef.Ext("user-42")); Console.WriteLine(string.Join(", ", items.Select(item => $"{item.Sku} x{item.Quantity}"))); // fire-sword x1, potion x1 ``` ## The limits | Limit | Where it comes from | What you get when you hit it | |---|---|---| | **Items owned** | `maxOwned` on the item. A durable item has `1` by default; a consumable has no limit. | `409 LIMIT_REACHED`, with `details.maxOwned` and `details.owned` | | **Packs bought** | `maxPerPlayer` on the pack | `409 LIMIT_REACHED`, with `details.maxPerPlayer` and `details.bought` | | **Currency** | You cannot spend more than the balance | `409 INSUFFICIENT_FUNDS`, with `details.required` and `details.available` | | **Items to consume** | You cannot consume more than the player owns | `409 INSUFFICIENT_FUNDS`, with `details.required` and `details.available` | | **Amounts** | One operation moves 1 to 1 000 000 000 000 units; a balance stays under 10¹⁵ | `400 VALIDATION_FAILED`, or `409 LIMIT_REACHED` | A second purchase of a durable item shows the first limit: ```json title="Buying the fire-sword twice" { "error": { "code": "LIMIT_REACHED", "message": "Item ownership limit exceeded", "details": { "maxOwned": 1, "owned": 1 } } } ``` A durable item **cannot be consumed**: `409 CONFLICT`. It stays. Use `consume` only on consumables. ```json title="Consuming the fire-sword" { "error": { "code": "CONFLICT", "message": "A durable item cannot be consumed" } } ``` Treat both as normal cases. In a shop, hide the "buy" button of an item the player already owns, and show a message instead of an error. ## Who may do what | Action | Server (secret key) | Browser (publishable key + token) | |---|---|---| | Grant currency | yes | **never** | | Grant an item | yes | **never** | | Buy an item with a currency | yes | yes, if the item is « Achetable depuis le jeu » | | Consume an item | yes | yes | | Spend currency without an item | yes | yes | An item that is not buyable from the game answers `403 FORBIDDEN` when the browser tries to buy it. Keep valuable items that way, and sell them from your server, which can check the rules of your game first. ## Where next - [Browser SDK](/docs/sdk/browser): `spend`, `buyItem` and `consume` in the browser. - [Wallets and ledger](/docs/concepts/wallets-and-ledger) - [Errors](/docs/concepts/errors) --- # Testing Source: https://gamecoin.apilow.com/docs/guides/testing > Try your whole integration without spending money, with the test environment, simulated payments, test players and the demo game. ## The test environment Every game has a **test** environment, picked by the test keys (`gc_sk_test_…`, `gc_pk_test_…`). It behaves like the real thing, with two differences: - **Payments are simulated.** The payment page shows « Payer » and « Refuser » buttons. No money moves. - **Data is disposable.** Players, balances, the ledger, the inventory and orders of `test` are separate from those of `live`, and you can leave them behind. Your **catalog** (currencies, items, packs) is shared between the two environments. You define it once and test it as it will be sold. > [!NOTE] > `test` is the only environment you can use today. Real payments are not available yet, so nothing you test here costs money, and nothing you test here is real. ## Four ways to try it | Way | You write code? | Good for | |---|---|---| | The dashboard's test players | No | Seeing balances, orders and refunds without any code | | The demo game | No | Seeing the browser SDK sell your own catalog | | `curl` or a server SDK | A little | Checking a call, a header or an error | | Your own game | Yes | The real integration | ### Test players in the dashboard The dashboard is in French for now. Open your game, go to « Joueurs », and check that the environment (« Environnement ») is **Test**. Then: 1. « Nouveau joueur de test »: give it an id such as `joueur-test-1`. This is an `ext:` player: its API name is `ext:joueur-test-1`. 2. On the player's page, « Donner de la monnaie » grants currency, and « Simuler un achat » opens a simulated payment for a pack of your choice, in a new tab. 3. Pay in that tab. Back on the player's page, the balance, the inventory and the order are updated. 4. In « Commandes », open the order, and use « Rembourser » to try a refund. « Bloquer » on the player's page blocks the player: all their client calls answer `PLAYER_BLOCKED`. ### The demo game The demo is a small clicker built on the browser SDK. It reads **your** catalog and shows the balance live, sells your packs and items, and lets you consume them. From your game's overview in the dashboard, use « Essayer dans le jeu de démonstration »: it opens the demo with your test key. You can also open [the demo game](/demo/index.html) and paste a publishable test key on its start screen. The demo is in French. Its code is a good example of the SDK in a real page. ## The simulated payment When you create a checkout in `test`, its payment page offers two buttons: | Button | What happens | Order status | Where the player goes | |---|---|---|---| | « Payer » | The payment succeeds. The pack is delivered at once. | `fulfilled` | `successUrl`, with `?order=&status=fulfilled` | | « Refuser » | The payment fails. Nothing is delivered. | `failed` | `cancelUrl`, with `?order=&status=failed` | If the player closes the page without choosing, the order stays `pending` and expires after one hour. The page also lets the buyer change the country, so you can see how the VAT changes. See [Selling packs](/docs/guides/selling-packs). ## A test plan Try each of these before you call your integration done. Every one is a real case your players will meet. | Case | How to try it | Expect | |---|---|---| | A new player | Open the game in a fresh browser profile or a private window | A new anonymous player, with balances at 0 | | The same player returns | Reload the page | Same player, same balance | | A paid pack | Pay in the simulated page | Status `fulfilled`, balance up by the pack, `change` event fired | | A declined payment | Press « Refuser » | Status `failed`, balance unchanged, a clear message | | A closed payment window | Close it without paying | Status `pending`, balance unchanged | | Not enough currency | Spend more than the balance | `INSUFFICIENT_FUNDS` with `required` and `available`; your shop opens | | An item at its limit | Buy a durable item twice | `LIMIT_REACHED`; your game shows "already owned" | | A double click | Click "buy" twice quickly | Two purchases: each call has its own key. Disable the button while a call runs. A second `gc.checkout()` during the first fails with `CONFLICT`. | | A retry | Send the same write twice with one idempotency key | The same answer, `Idempotent-Replayed: true` | | An expired token | Wait an hour, or use a token you altered | The SDK renews it once; your `onTokenExpired` runs | | A blocked player | « Bloquer » in the dashboard | `PLAYER_BLOCKED` on every call | | A refund | « Rembourser » on an order, after spending some of it | A debt, or a shortfall: see [Refunds](/docs/guides/refunds) | | A popup blocker | Turn it on in the browser | The SDK falls back to a redirect, and `gc.returnedOrder` has the order on return | ## Test with a script For automated tests, give each run its own players. The ledger never forgets, so a player you reuse carries the balance of the last run. Make the id from the run: ```bash tab="curl" PLAYER="ext%3Aci-$(date +%s)" curl -X POST "https://gamecoin.apilow.com/api/v1/players/$PLAYER/wallet/grant" \ -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \ -H "Idempotency-Key: seed-$PLAYER" \ -H "Content-Type: application/json" \ -d '{"currency":"gems","amount":100}' ``` ```javascript tab="Node.js" import assert from "node:assert/strict"; import { ErrorCode, GameCoinError } from "@apilow/gamecoin"; const player = ext(`ci-${Date.now()}`); await gamecoin.wallet.grant(player, { currency: "gems", amount: 100 }); await gamecoin.wallet.spend(player, { currency: "gems", amount: 30 }); assert.equal((await gamecoin.wallet.get(player)).balances.find((b) => b.currency === "gems").total, 70); await assert.rejects( gamecoin.wallet.spend(player, { currency: "gems", amount: 1000 }), (error) => error instanceof GameCoinError && error.code === ErrorCode.INSUFFICIENT_FUNDS && error.details.available === 70, ); console.log("ok"); ``` ```python tab="Python" import time from gamecoin import ErrorCode, GameCoinError player = ext(f"ci-{int(time.time() * 1000)}") gamecoin.wallet.grant(player, currency="gems", amount=100) gamecoin.wallet.spend(player, currency="gems", amount=30) assert gamecoin.wallet.get(player).balance_of("gems").total == 70 try: gamecoin.wallet.spend(player, currency="gems", amount=1000) except GameCoinError as error: assert error.code == ErrorCode.INSUFFICIENT_FUNDS and error.details["available"] == 70 else: raise AssertionError("the spend should have been refused") print("ok") ``` ```php tab="PHP" use Apilow\GameCoin\ErrorCode; use Apilow\GameCoin\GameCoinException; $player = PlayerRef::ext('ci-' . (int) (microtime(true) * 1000)); $gamecoin->wallet->grant($player, ['currency' => 'gems', 'amount' => 100]); $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 30]); if ($gamecoin->wallet->get($player)->balanceOf('gems')?->total !== 70) { throw new RuntimeException('expected 70 gems'); } try { $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 1000]); throw new RuntimeException('the spend should have been refused'); } catch (GameCoinException $e) { if ($e->errorCode !== ErrorCode::INSUFFICIENT_FUNDS || $e->details['available'] !== 70) { throw $e; } } echo "ok\n"; ``` ```go tab="Go" player := gamecoin.Ext(fmt.Sprintf("ci-%d", time.Now().UnixMilli())) if _, err := client.Wallet.Grant(ctx, player, gamecoin.GrantParams{Currency: "gems", Amount: 100}); err != nil { log.Fatal(err) } if _, err := client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 30}); err != nil { log.Fatal(err) } wallet, err := client.Wallet.Get(ctx, player) if err != nil { log.Fatal(err) } for _, b := range wallet.Balances { if b.Currency == "gems" && b.Total != 70 { log.Fatalf("expected 70 gems, got %d", b.Total) } } _, err = client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 1000}) var gcErr *gamecoin.Error if !errors.As(err, &gcErr) || gcErr.Code != gamecoin.CodeInsufficientFunds { log.Fatalf("the spend should have been refused, got %v", err) } if available, _ := gcErr.DetailInt64("available"); available != 70 { log.Fatalf("expected 70 available, got %d", available) } fmt.Println("ok") ``` ```csharp tab="C#" using Apilow.GameCoin; var player = PlayerRef.Ext($"ci-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"); await gamecoin.Wallet.GrantAsync(player, new GrantRequest { Currency = "gems", Amount = 100 }); await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 30 }); var wallet = await gamecoin.Wallet.GetAsync(player); if (wallet.Balances.Single(b => b.Currency == "gems").Total != 70) throw new InvalidOperationException("expected 70 gems"); try { await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 1000 }); throw new InvalidOperationException("the spend should have been refused"); } catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.InsufficientFunds && ex.Details["available"].GetInt64() == 70) { Console.WriteLine("ok"); } ``` Keep a game for testing, with its own `test` keys, and put its secret key in the secret store of your CI, never in the repository. The server SDKs generate an idempotency key for every call, so the script above needs none. With `curl` you give one. To test a payment from a script, create the checkout through the API, then open `checkoutUrl` and press « Payer » once by hand, or drive the page with a browser test tool. Then read the order with `GET /orders/{orderId}`. ## Go live The `live` environment is for verified studios and real payments, and neither is available yet. Because the key picks the environment, going live later means changing the keys that your game and your server read, and nothing else. Until then, this page is how to be ready. ## Where next - [Selling packs](/docs/guides/selling-packs) - [Refunds](/docs/guides/refunds) - [Errors](/docs/concepts/errors) --- # Browser SDK Source: https://gamecoin.apilow.com/docs/sdk/browser > The complete reference of gamecoin.js, the one-file SDK that lets a browser game read, spend, buy, consume, sell packs, redeem gift codes and open the shop with a publishable key. ## Overview `gamecoin.js` is the SDK for the browser. It is one file, with no dependency and no build step (ES2020). It talks to the client API (`/api/v1/client/**`) with your **publishable key**. A browser can read, spend, buy an item with a currency, consume an item, pay for a pack, redeem a gift code and open the [shop widget](/docs/sdk/widget). It can never credit a player by itself: the SDK has no `grant`, no item grant, no refund. Coins come from a paid order or from your server, with **one exception**: a [gift code](/docs/guides/codes) that your studio created, which gives free coins and items, once per player, and with a limited number of uses (see [redeemCode()](#redeemcode)). See the [Security model](/docs/concepts/security-model). The SDK runs in two modes: | Mode | You pass | The SDK | |---|---|---| | **Anonymous player** (a game without a backend) | Only `publishableKey` | Creates an anonymous player on the first visit and remembers it in `localStorage` | | **Your player** (a game with a backend) | `publishableKey` and `playerToken` | Creates nothing: it acts as the player of the token | ## Install Load the file with a classic script tag, from your GameCoin host. It adds a global `GameCoin` and takes the host it was loaded from as the place to call. ```html ``` The file is also a CommonJS module (`module.exports`), for a bundler or a test. There, it cannot know where it was loaded from, so pass `baseUrl`. Download it and ship it with your game if you prefer not to load it from our host. ```javascript const GameCoin = require("./gamecoin.js"); const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…", baseUrl: "https://gamecoin.apilow.com" }); ``` `GameCoin.version` is the version of the file (`"1.2.0"`). ## GameCoin.init() ```text GameCoin.init(options) → Promise ``` `init` makes the player ready (it creates or recognizes them), reads their wallet and inventory, and returns the client. Await it before anything else. | Option | Type | Default | Meaning | |---|---|---|---| | `publishableKey` | string | required | A `gc_pk_…` key. A secret key (`gc_sk_…`) is refused: never put one in a browser. | | `playerToken` | string | none | A player token issued by **your server** (`POST /players/{player}/tokens`). With it, the SDK never creates or stores a player. | | `onTokenExpired` | `async () => string` | none | Called once when a request is refused because the token expired. Return a new token. See [expired tokens](#expired-tokens). | | `baseUrl` | string | the origin of the ` ``` `GameCoinShop.version` is the version of the file (`"1.0.0"`). The file is also a CommonJS module (`module.exports` is a function that builds a widget from `{ window, document, scriptOrigin }`), which is what its tests use. If the script is not loaded from a ` ``` Call `open` from a click, a key press or a tap: the player's browser then lets the shop open the payment window without blocking it. ## Open a modal ```text GameCoinShop.open(options) → { close() } ``` It opens a dialog over your page and returns a handle; `GameCoinShop.close()` closes it too. Only one modal at a time: opening a second one closes the first. ```javascript const shop = GameCoinShop.open({ gameSlug: "my-game", token, locale: "nl", theme: "dark" }); // later, for example when the level starts: shop.close(); ``` ## Mount the shop in your page ```text GameCoinShop.mount(element, options) → { destroy() } ``` `element` is an element or a CSS selector. The shop fills the width of the element; its iframe grows with the content, so the **page** scrolls, not the iframe. There is no dialog, no scroll lock and no close button. Pick `theme` to match the page behind it. Several mounts can live together; `destroy()` removes one. ```javascript const stand = GameCoinShop.mount("#shop-stand", { gameSlug: "my-game", token, theme: "light" }); // when the player leaves the screen: stand.destroy(); ``` ## Options | Option | Type | Default | Meaning | |---|---|---|---| | `gameSlug` | string | required | The slug of your game (lowercase letters, digits and dashes). | | `token` | string | required | A short-lived player token, from your server or the client API. | | `locale` | `"fr"`, `"en"` or `"nl"` | The `lang` of your page, then the browser language, then `"en"` | The language of the shop and of the widget's own texts. | | `theme` | `"light"` or `"dark"` | `"light"` | The colors of the shop. The background is transparent; the modal draws its own. | | `baseUrl` | string | The origin of the ` ``` `gc.openShop(options)` does what the rest of this page describes, with the player of the SDK: - it **loads `gamecoin-widget.js` for you**, once, from the same place as `gamecoin.js` (you can leave the ` ``` The player of these examples is the `ext:user-42` of the server API pages, so that you find the same wallet and inventory. ## Keys and tokens | Header | Value | Needed on | |---|---|---| | `X-GameCoin-Key` | The publishable key, `gc_pk_test_…` or `gc_pk_live_…` | Every client route | | `Authorization` | `Bearer gc_pt_…`, a player token | The routes that act on a player: `/client/me/**` | A player token lasts one hour and is tied to one player, one game and one environment. A secret key sent to a client route is refused with `403 FORBIDDEN`, and a missing or invalid token with `401 UNAUTHENTICATED`. When a request answers `401` and you hold a token, ask for a new one (your backend for a player it created, [Get a new player token](#get-a-new-player-token) for an anonymous player) and send the same request again, **with the same `Idempotency-Key`**: if the first attempt did go through, you get its answer back instead of a second change. A blocked player is refused on every client route with `403 PLAYER_BLOCKED`. ## CORS Each game has a list of **allowed origins** (game settings in the dashboard, one origin per line, e.g. `https://play.example.com`). A client API request whose `Origin` is on the list is answered with that origin in `Access-Control-Allow-Origin` (and `Vary: Origin`). A request from another origin is refused with `403 FORBIDDEN` (`fieldErrors.Origin = ORIGIN_NOT_ALLOWED`). With an empty list the `test` environment accepts any origin (`*`) and `live` accepts none. Requests without an `Origin` header (game engines, servers) are never affected. A preflight `OPTIONS` request is answered `204` without any authentication. The response lists the headers a browser may send (`Authorization`, `Content-Type`, `X-GameCoin-Key`, `Idempotency-Key`), and exposes `Idempotent-Replayed` and `Retry-After` to your code. ```bash curl -i -X OPTIONS "https://gamecoin.apilow.com/api/v1/client/me/wallet/spend" \ -H "Origin: https://example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: authorization,x-gamecoin-key,idempotency-key,content-type" ``` ```http HTTP/1.1 204 No Content Access-Control-Allow-Headers: Authorization, Content-Type, X-GameCoin-Key, Idempotency-Key Access-Control-Allow-Methods: POST, OPTIONS Access-Control-Allow-Origin: * Access-Control-Max-Age: 600 ``` The **server API sends no CORS header**: a browser cannot call it, and it must not, because it needs your secret key. ## Return URLs of a checkout A browser can start a checkout, but it cannot choose where the player goes next. `successUrl` and `cancelUrl` must have **the same origin** as the page that calls: the `Origin` header the browser adds to the request. Any other address is refused with `400 VALIDATION_FAILED` and `ORIGIN_MISMATCH` in `details.fieldErrors`. A client that does not send an `Origin` header, such as curl, cannot pass return URLs at all. `country` is not accepted: the buyer picks it on the payment page. The browser SDK handles this: it opens the payment page in a window and needs no return URL, or, in `redirect` mode, it returns to the current page. See [Order a currency pack](#order-a-currency-pack). ## A client never credits, except with a gift code There is no route to grant currency, to grant an item, to adjust a balance or to refund an order in the client API. This is the point of it: the publishable key is public, so anyone can call these routes with it, and nothing they can do creates value. A player can spend what they have and pay with real money; coins appear in a wallet only through your server (a [grant](/docs/api/wallet#grant-currency)), through a paid order, or from a **gift code** that you created. The gift code is the one exception, and it is safe because the value is decided by your studio, not by the client: a code is random, limited in uses, works once per player, gives **free coins only** (never paid coins) and cannot be passed to another player. See [Redeem a gift code](/docs/api/codes#redeem-a-gift-code) and [Security model](/docs/concepts/security-model). ## Get the catalog Returns the active currencies, items and packs of the game. It needs only the publishable key, so you can show a shop before a player is known. ```endpoint GET /client/catalog ``` **Authentication:** publishable key. **Idempotency:** not needed. **Parameters:** none. ```bash tab="curl" curl "https://gamecoin.apilow.com/api/v1/client/catalog" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" ``` ```javascript tab="Browser" const catalog = await gc.getCatalog(); for (const item of catalog.items) console.log(item.sku, item.type, item.price?.amount); for (const pack of catalog.packs) console.log(pack.sku, pack.priceCents); ``` **Response** `200 OK`: a [Catalog](/docs/api/objects#catalog), the same as [the server API](/docs/api/catalog#get-the-catalog) returns. ```json { "currencies": [ { "code": "gems", "name": "Gems", "purchasable": true }, { "code": "gold", "name": "Gold", "purchasable": false } ], "items": [ { "sku": "potion", "name": "Potion", "description": null, "type": "consumable", "price": { "currency": "gems", "amount": 20 }, "maxOwned": null, "clientPurchasable": true, "metadata": {} }, { "sku": "fire-sword", "name": "Fire sword", "description": null, "type": "durable", "price": { "currency": "gems", "amount": 300 }, "maxOwned": 1, "clientPurchasable": true, "metadata": {} } ], "packs": [ { "sku": "starter", "name": "Starter pack", "description": null, "priceCents": 99, "currency": "EUR", "grants": [{ "currency": "gems", "amount": 100, "bonus": 20 }], "items": [{ "sku": "potion", "quantity": 1 }], "badge": null, "maxPerPlayer": 1, "availability": null }, { "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, "availability": null } ], "prelaunch": false, "launchAt": null } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 401 | `UNAUTHENTICATED` | The `X-GameCoin-Key` header is missing, or the key is unknown or revoked | Send the publishable key of your game. | | 403 | `FORBIDDEN` | A secret key was sent | Never use a secret key in a client: use the publishable key. | | 429 | `RATE_LIMITED` | More than 120 requests a minute from one IP address without a token | Wait `Retry-After` seconds. | **Notes** - `items[].clientPurchasable` tells which items [Buy an item with currency](#buy-an-item-with-currency) accepts from a client. ## Create an anonymous player Creates a player for a game that has no backend, and returns its credentials. Use it the first time a device opens the game. The browser SDK does it for you. ```endpoint POST /client/players ``` **Authentication:** publishable key. **Idempotency:** none: every call creates a **new** player. Call it once per device. **Body** (JSON; optional, an empty body means `{}`) | Field | Type | Required | Rules | |---|---|---|---| | `displayName` | string | no | Name shown in your game: 1 to 80 characters once trimmed. | ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/client/players" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Content-Type: application/json" \ -d '{"displayName": "Guest"}' ``` ```javascript tab="Browser" // With no playerToken, init() creates the anonymous player the first time and remembers it in localStorage. const guest = await GameCoin.init({ publishableKey: "gc_pk_test_…", displayName: "Guest" }); console.log(guest.player.id, guest.player.kind); // hosted ``` **Response** `201 Created`: the player and their credentials. | Field | Type | Description | |---|---|---| | `player` | [Player](/docs/api/objects#player) | The new player: `kind` is `hosted` and `externalId` is `null`. | | `playerSecret` | string | The secret of the player, **returned only here**. Store it on the device: it is the only way to get new tokens. | | `token` | string | A first player token. | | `expiresAt` | string | Expiry of `token`, one hour after issue. | ```json { "player": { "id": "665f1c2e8a3b4d5e6f7081c6", "externalId": null, "kind": "hosted", "displayName": "Guest", "country": null, "blocked": false, "createdAt": "2026-10-05T22:14:41.141Z" }, "playerSecret": "bD7QMp…", "token": "gc_pt_…", "expiresAt": "2026-10-05T23:14:41.000Z" } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 400 | `VALIDATION_FAILED` | An unknown field, or a `displayName` that is empty or longer than 80 characters | Read `details.fieldErrors`. | | 401 | `UNAUTHENTICATED` | The publishable key is missing, unknown or revoked | Send the publishable key of your game. | | 403 | `FORBIDDEN` | The game does not allow anonymous players, or a secret key was sent | Issue tokens from your backend instead. | | 429 | `RATE_LIMITED` | More than 30 anonymous players an hour from one IP address | Wait `Retry-After` seconds. | **Notes** - Keep `player.id` and `playerSecret` on the device, in a place that survives a restart. A player whose secret is lost cannot be recovered. Never ship them to another player. - The player is anonymous: no `ext:` id, no way to find them again from your backend. If your game has accounts, use the server API and [issue tokens](/docs/api/players#issue-a-player-token) for your own users. - The browser SDK stores the player in `localStorage` and renews the token itself. ## Get a new player token Exchanges the secret of an anonymous player for a new token, when the previous one has expired. The browser SDK calls it for you when a request answers `401`. ```endpoint POST /client/players/token ``` **Authentication:** publishable key. **Idempotency:** not needed. **Body** | Field | Type | Required | Rules | |---|---|---|---| | `playerId` | string | yes | The `player.id` returned by [Create an anonymous player](#create-an-anonymous-player): 1 to 64 characters. | | `playerSecret` | string | yes | The `playerSecret` returned with it: 1 to 200 characters. | ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/client/players/token" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Content-Type: application/json" \ -d '{"playerId": "665f1c2e8a3b4d5e6f7081c6", "playerSecret": ""}' ``` ```javascript tab="Browser" // The SDK keeps the player id and secret of an anonymous player and calls this route when a request answers 401: // there is nothing to call. A game whose tokens come from a backend gives init() an onTokenExpired callback instead. const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" }); await gc.refresh(); // an expired token is renewed on the way ``` **Response** `200 OK`: a new token. | Field | Type | Description | |---|---|---| | `token` | string | The player token, `gc_pt_…`. | | `expiresAt` | string | Expiry, one hour after issue. | ```json { "token": "gc_pt_…", "expiresAt": "2026-10-05T23:14:41.000Z" } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 400 | `VALIDATION_FAILED` | A field is missing, too long or unknown | Read `details.fieldErrors`. | | 401 | `UNAUTHENTICATED` | The secret is wrong, the player is unknown, or the player is not anonymous: the three cases look the same | The player cannot be recovered: create a new anonymous player. | ```json { "error": { "code": "UNAUTHENTICATED", "message": "Invalid player credentials" } } ``` **Notes** - It works for **anonymous** players only. A player created by your backend (`ext:`) has no secret: ask your server for a token with [Issue a player token](/docs/api/players#issue-a-player-token). ## Get the token player Returns the player of the token, with their wallet and their inventory, in one call. Use it when your game starts and after anything that may have changed the wallet. ```endpoint GET /client/me ``` **Authentication:** publishable key and player token. **Idempotency:** not needed. **Parameters:** none. ```bash tab="curl" curl "https://gamecoin.apilow.com/api/v1/client/me" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" ``` ```javascript tab="Browser" const { wallet, inventory } = await gc.refresh(); // reads GET /client/me and updates the cache console.log(gc.player.id, wallet.balances.map((b) => `${b.currency} ${b.total}`), inventory); console.log(gc.balance("gems"), gc.owned("potion")); // cached copies: no request ``` **Response** `200 OK`: the player, their wallet and their owned items. | Field | Type | Description | |---|---|---| | `player` | [Player](/docs/api/objects#player) | The player of the token. | | `wallet` | [Wallet](/docs/api/objects#wallet) | Their balances, one per active currency, zeros included. | | `inventory` | array of [InventoryItem](/docs/api/objects#inventoryitem) | The items they own (quantity above 0). | ```json { "player": { "id": "665f1c2e8a3b4d5e6f708192", "externalId": "user-42", "kind": "external", "displayName": "Alice", "country": "BE", "blocked": false, "createdAt": "2026-10-05T22:14:34.808Z" }, "wallet": { "playerId": "665f1c2e8a3b4d5e6f708192", "balances": [ { "currency": "gems", "paid": 500, "bonus": 100, "total": 600 }, { "currency": "gold", "paid": 0, "bonus": 0, "total": 0 } ] }, "inventory": [{ "sku": "potion", "quantity": 3, "updatedAt": "2026-10-05T22:14:38.396Z" }] } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 401 | `UNAUTHENTICATED` | The token is missing, invalid or expired (the cases look the same), or it was issued for another game or environment | Get a new token. | | 403 | `PLAYER_BLOCKED` | The player is blocked | Tell the player; nothing can be done from the client. | | 429 | `RATE_LIMITED` | More than 120 requests a minute for this player | Wait `Retry-After` seconds. | **Notes** - The browser SDK reads this route at `init()` and keeps a copy: `gc.player`, `gc.wallet` and `gc.inventory` hold it, `gc.balance("gems")` and `gc.owned("potion")` answer without a request, and a `change` event fires when the copy moves. `gc.refresh()` reads the route again and returns `{ wallet, inventory }`. ## Spend currency Debits a currency from the wallet of the token player. Use it when the player pays coins for something in your game. ```endpoint POST /client/me/wallet/spend ``` **Authentication:** publishable key and player token. **Idempotency:** required (`Idempotency-Key` header). **Body** | Field | Type | Required | Rules | |---|---|---|---| | `currency` | string | yes | A currency code of the catalog (2 to 24 lowercase characters). | | `amount` | integer | yes | A whole number from 1 to 1,000,000,000,000. | | `reason` | string | no | Free text, at most 500 characters, recorded in the ledger. There is no `metadata` on the client API. | ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/client/me/wallet/spend" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" \ -H "Idempotency-Key: client-revive-user-42" \ -H "Content-Type: application/json" \ -d '{"currency": "gems", "amount": 10, "reason": "revive"}' ``` ```javascript tab="Browser" const { entry, balance } = await gc.spend("gems", 10, "revive", { idempotencyKey: "client-revive-user-42" }); console.log(entry.bonusDelta, balance.total, gc.balance("gems")); ``` **Response** `200 OK`: the ledger entry and the new balance, as for [the server API](/docs/api/wallet#spend-currency). ```json { "entry": { "id": "6ac4215160ea7eba011aaca3", "type": "spend", "currency": "gems", "paidDelta": 0, "bonusDelta": -10, "balanceAfter": { "paid": 500, "bonus": 90, "total": 590 }, "reason": "revive", "metadata": {}, "ref": {}, "createdAt": "2026-10-05T22:14:41.733Z" }, "balance": { "currency": "gems", "paid": 500, "bonus": 90, "total": 590 } } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing or empty | Send one. The browser SDK always does. | | 400 | `VALIDATION_FAILED` | An unknown field (`metadata` included); `amount` not an integer between 1 and 10¹²; `currency` malformed | Read `details.fieldErrors`. | | 401 | `UNAUTHENTICATED` | The token is missing, invalid or expired | Get a new token and send the request again with the same key. | | 403 | `PLAYER_BLOCKED` | The player is blocked | Nothing can be done from the client. | | 404 | `NOT_FOUND` | The currency is not in the catalog | Check the code against [Get the catalog](#get-the-catalog). | | 409 | `INSUFFICIENT_FUNDS` | The balance is lower than `amount`: `details.required` and `details.available` | Offer a pack. Nothing was spent. | | 409 | `IDEMPOTENCY_CONFLICT` | The key was already used with a different request | Use a new key for a new spend. | **Notes** - Same rules as the server route: bonus coins first, all or nothing. A key you choose makes a spend safe against a double click or a page reload: `{ idempotencyKey }` is the last argument of every changing method of the SDK. Without it, the SDK uses a random key per call and reuses it when it replays the request. ## Buy an item with currency Buys an item for the token player: the price is debited and the item delivered in one step, as for [the server API](/docs/api/purchases#buy-an-item-with-currency). Only the items flagged `clientPurchasable` can be bought from a client. ```endpoint POST /client/me/purchases ``` **Authentication:** publishable key and player token. **Idempotency:** required (`Idempotency-Key` header). **Body** | Field | Type | Required | Rules | |---|---|---|---| | `sku` | string | yes | The sku of an item that has a price and is `clientPurchasable`: 1 to 48 characters. | | `quantity` | integer | no | A whole number from 1 to 1,000,000,000,000. Default 1. | ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/client/me/purchases" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" \ -H "Idempotency-Key: client-buy-potion-user-42" \ -H "Content-Type: application/json" \ -d '{"sku": "potion", "quantity": 1}' ``` ```javascript tab="Browser" const { entry, balance, item } = await gc.buyItem("potion", 1, { idempotencyKey: "client-buy-potion-user-42" }); console.log(balance.total, item.quantity, gc.owned("potion")); ``` **Response** `200 OK`: the debit, the new balance and the item, as for [the server API](/docs/api/purchases#buy-an-item-with-currency). ```json { "entry": { "id": "6ac42151c3d98b9534c5ac2d", "type": "spend", "currency": "gems", "paidDelta": 0, "bonusDelta": -20, "balanceAfter": { "paid": 500, "bonus": 70, "total": 570 }, "reason": null, "metadata": {}, "ref": { "itemSku": "potion" }, "createdAt": "2026-10-05T22:14:41.944Z" }, "balance": { "currency": "gems", "paid": 500, "bonus": 70, "total": 570 }, "item": { "sku": "potion", "quantity": 4, "updatedAt": "2026-10-05T22:14:41.958Z" } } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing or empty | Send one. The browser SDK always does. | | 400 | `VALIDATION_FAILED` | An unknown field; `sku` empty or too long; `quantity` not an integer between 1 and 10¹² | Read `details.fieldErrors`. | | 401 | `UNAUTHENTICATED` | The token is missing, invalid or expired | Get a new token and send the request again with the same key. | | 403 | `FORBIDDEN` | The item is not `clientPurchasable` | Sell it from your backend with [Buy an item with currency](/docs/api/purchases#buy-an-item-with-currency). | | 403 | `PLAYER_BLOCKED` | The player is blocked | Nothing can be done from the client. | | 404 | `NOT_FOUND` | The item is not in the catalog | Check the sku against [Get the catalog](#get-the-catalog). | | 409 | `INSUFFICIENT_FUNDS` | The wallet is lower than the price times `quantity` | Offer a pack. Nothing was debited. | | 409 | `LIMIT_REACHED` | The purchase would take the player over the item's `maxOwned` | The player already has the item. | | 409 | `CONFLICT` | The item has no price in currency | Sell the item another way. | | 409 | `IDEMPOTENCY_CONFLICT` | The key was already used with a different request | Use a new key for a new purchase. | **Notes** - Tick « Achetable depuis le jeu (API cliente) » on an item in the dashboard (in French for now) only if a player may buy it without your server in the loop. Anything you want to check first (a level, a quest) belongs to your backend. ## Consume an item Removes units of a consumable item the token player owns. ```endpoint POST /client/me/inventory/consume ``` **Authentication:** publishable key and player token. **Idempotency:** required (`Idempotency-Key` header). **Body** | Field | Type | Required | Rules | |---|---|---|---| | `sku` | string | yes | The sku of an item of the catalog: 1 to 48 characters. | | `quantity` | integer | no | A whole number from 1 to 1,000,000,000,000. Default 1. | ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/client/me/inventory/consume" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" \ -H "Idempotency-Key: client-use-potion-user-42" \ -H "Content-Type: application/json" \ -d '{"sku": "potion"}' ``` ```javascript tab="Browser" const { item } = await gc.consume("potion", 1, { idempotencyKey: "client-use-potion-user-42" }); console.log(item.sku, item.quantity, gc.owned("potion")); ``` **Response** `200 OK`: the item with its remaining quantity, even when it reaches 0. ```json { "item": { "sku": "potion", "quantity": 3, "updatedAt": "2026-10-05T22:14:42.152Z" } } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing or empty | Send one. The browser SDK always does. | | 400 | `VALIDATION_FAILED` | An unknown field; `sku` empty or too long; `quantity` not an integer between 1 and 10¹² | Read `details.fieldErrors`. | | 401 | `UNAUTHENTICATED` | The token is missing, invalid or expired | Get a new token and send the request again with the same key. | | 403 | `PLAYER_BLOCKED` | The player is blocked | Nothing can be done from the client. | | 404 | `NOT_FOUND` | The item is not in the catalog | Check the sku against [Get the catalog](#get-the-catalog). | | 409 | `INSUFFICIENT_FUNDS` | The player owns fewer units than `quantity`: `details.required` and `details.available` | Nothing was consumed. | | 409 | `CONFLICT` | The item is `durable` and cannot be consumed | Only consume `consumable` items. | | 409 | `IDEMPOTENCY_CONFLICT` | The key was already used with a different request | Use a new key for a new consumption. | **Notes** - Same rules as [the server route](/docs/api/inventory#consume-an-item). A client can consume what the player owns; it cannot give them anything. ## Order a currency pack Creates an order for a pack, for the token player, and returns the hosted payment page. In a browser, the SDK opens it in a window and tells you when the order is over. Payments are simulated in the test environment. ```endpoint POST /client/me/checkouts ``` **Authentication:** publishable key and player token. **Idempotency:** required (`Idempotency-Key` header). **Body** | Field | Type | Required | Rules | |---|---|---|---| | `packSku` | string | yes | The sku of an active pack: 1 to 48 characters. | | `successUrl` | string | no | Where to send the player after a successful payment. Same origin as the request's `Origin` header: see [Return URLs](#return-urls-of-a-checkout). | | `cancelUrl` | string | no | Where to send the player when the payment fails. Same rule. | | `locale` | string | no | Language of the hosted payment page, the receipt and the e-mails of this order: `fr`, `en` or `nl`. Any other value is ignored (no error). Without it, the payment page follows the player's browser language (`Accept-Language`), then French. | | `creatorCode` | string | no | A [creator code](/docs/guides/codes#creator-codes) typed by the buyer. A refused code answers `400 VALIDATION_FAILED` with `fieldErrors.creatorCode = ["CODE_INVALID"]` and no order is created. The attempts are limited per player and per IP address. | `country` is not accepted here: it exists on the [server route](/docs/api/checkouts#order-a-currency-pack) only. ```bash tab="curl" curl -X POST "https://gamecoin.apilow.com/api/v1/client/me/checkouts" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" \ -H "Idempotency-Key: client-checkout-gems-500-user-42" \ -H "Content-Type: application/json" \ -d '{"packSku": "gems-500"}' ``` ```javascript tab="Browser" // Call it from a click: the SDK opens the payment window before any request, or browsers block it. document.querySelector("#buy-gems").addEventListener("click", async () => { const order = await gc.checkout("gems-500"); // resolves when the order is over console.log(order.status); // "fulfilled" once the player has paid }); ``` **Response** `201 Created` for a new order, `200 OK` for a replay: the order and the payment page, as for [the server route](/docs/api/checkouts#order-a-currency-pack). ```json { "order": { "id": "665f1c2e8a3b4d5e6f7081b4", "playerId": "665f1c2e8a3b4d5e6f708192", "status": "pending", "pack": { "sku": "gems-500", "name": "Bag of gems" }, "amountCents": 499, "currency": "EUR", "country": "BE", "vatRateBp": 2100, "vatCents": 87, "netCents": 412, "createdAt": "2026-10-05T22:14:42.336Z", "paidAt": null, "fulfilledAt": null, "refundedAt": null, "creatorCode": null }, "checkoutUrl": "https://gamecoin.apilow.com/pay/665f1c2e8a3b4d5e6f7081b4?t=…" } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing or empty | Send one. The browser SDK always does. | | 400 | `VALIDATION_FAILED` | An unknown field (`country` included); `packSku` empty or too long; a return URL that is invalid, not `https`, or whose origin is not the one of the request (`ORIGIN_MISMATCH`) | Read `details.fieldErrors`. | | 401 | `UNAUTHENTICATED` | The token is missing, invalid or expired | Get a new token and send the request again with the same key. | | 402 | `PAYMENT_FAILED` | No payment provider is available for the environment | Use a `test` key: payments are simulated there. | | 403 | `PLAYER_BLOCKED` | The player is blocked | Nothing can be done from the client. | | 404 | `NOT_FOUND` | The pack is not in the catalog | Check the sku against [Get the catalog](#get-the-catalog). | | 409 | `LIMIT_REACHED` | The player already bought the pack as many times as its `maxPerPlayer` allows | Hide the pack for this player. | | 409 | `PACK_NOT_AVAILABLE` | The pack is outside its sale window: `details.startsAt` and `details.endsAt` | Show « coming soon » or « ended » with these dates. | | 409 | `PACK_SOLD_OUT` | The total stock of the pack is used up | Show « sold out ». The stock can come back if a pending order expires. | | 409 | `IDEMPOTENCY_CONFLICT` | The key was already used with a different request | Use a new key for a new order. | ```json { "error": { "code": "VALIDATION_FAILED", "message": "successUrl and cancelUrl must have the same origin as the Origin header of the request", "details": { "fieldErrors": { "successUrl": ["ORIGIN_MISMATCH"] } } } } ``` **Notes** - `gc.checkout(packSku, options)` resolves with the order read from the API once it is over (`fulfilled`, `failed`, `expired`…), or with its current status if the player closes the window without paying. A `fulfilled` order refreshes the wallet before the promise resolves. When the browser blocks the window, or with `{ mode: "redirect" }`, the SDK sends the current tab to the payment page and back to the current URL: `init()` then reads the order and exposes it as `gc.returnedOrder`. - As on the server, an order is created `pending`; the coins arrive when it is `fulfilled`. Read the order, not the query string of the return URL. - Return URLs are optional: the payment page has a built-in confirmation page that tells the window that opened it with `postMessage({ type: "gamecoin:order", orderId, status })`. ## Redeem a gift code The token player types a gift code and receives its free coins and items, once. The route is `POST /client/me/codes/redeem`, with the body `{ "code": "…" }` and an `Idempotency-Key`. It is described, with its errors, in [Redeem a gift code](/docs/api/codes#redeem-a-gift-code): any refused code answers `400 VALIDATION_FAILED` with `fieldErrors.code = ["CODE_INVALID"]`, and attempts are limited per player and per IP address. ## Get an order of the token player Returns an order of the token player, to follow a payment from the client. ```endpoint GET /client/me/orders/{orderId} ``` **Authentication:** publishable key and player token. **Idempotency:** not needed. **Path parameters** | Parameter | Type | Description | |---|---|---| | `orderId` | string | The id of an order of this player: 24 hexadecimal characters. | ```bash tab="curl" curl "https://gamecoin.apilow.com/api/v1/client/me/orders/665f1c2e8a3b4d5e6f7081b4" \ -H "X-GameCoin-Key: $GAMECOIN_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $GAMECOIN_PLAYER_TOKEN" ``` ```javascript tab="Browser" // Back from a redirect checkout (?order=…&status=… in the URL), init() has already read the order for you. if (gc.returnedOrder) console.log(gc.returnedOrder.id, gc.returnedOrder.status); ``` **Response** `200 OK`: an [Order](/docs/api/objects#order). ```json { "id": "665f1c2e8a3b4d5e6f7081b4", "playerId": "665f1c2e8a3b4d5e6f708192", "status": "pending", "pack": { "sku": "gems-500", "name": "Bag of gems" }, "amountCents": 499, "currency": "EUR", "country": "BE", "vatRateBp": 2100, "vatCents": 87, "netCents": 412, "createdAt": "2026-10-05T22:14:42.336Z", "paidAt": null, "fulfilledAt": null, "refundedAt": null, "creatorCode": null } ``` **Errors** | Status | Code | When | What to do | |---|---|---|---| | 401 | `UNAUTHENTICATED` | The token is missing, invalid or expired | Get a new token. | | 404 | `NOT_FOUND` | No order has this id for this player. An order of another player answers `404` too | Check the id. | **Notes** - The browser SDK has no method to read an order by id: `gc.checkout()` follows the order it creates, and `gc.returnedOrder` is the order of a redirect checkout. - Your server reads any order of the game with [Get an order](/docs/api/orders#get-an-order).