# 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"
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
```

### 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"
<script type="module">
  const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });

  console.log(gc.player.id);              // the anonymous player, remembered in localStorage
  console.log(gc.balance("gems"));        // 0 until the player buys something
  const catalog = await gc.getCatalog();  // { currencies, items, packs }
</script>
```

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"
<button id="buy-pack">Buy 500 gems</button>
<button id="buy-potion">Buy a potion (20 gems)</button>
<button id="use-potion">Drink a potion</button>
<p>Gems: <span id="gems">0</span></p>

<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
<script type="module">
  const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });

  // "change" fires after every purchase, spend, consume or refresh.
  const render = () => (document.getElementById("gems").textContent = gc.balance("gems"));
  gc.on("change", render);
  render();

  // A pack is paid in euros (simulated in test): checkout() opens the payment page in a window.
  // Call it straight from a click, or the browser blocks the window.
  document.getElementById("buy-pack").onclick = async () => {
    const order = await gc.checkout("gems-500");
    console.log(order.status); // "fulfilled" | "failed" | "pending" (window closed before paying)
  };

  // An item is paid with the game's currency: no window.
  document.getElementById("buy-potion").onclick = () => gc.buyItem("potion", 1).catch(console.error);
  document.getElementById("use-potion").onclick = () => gc.consume("potion", 1).catch(console.error);
</script>
```

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.

<!-- tabs:start -->
```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
```
<!-- tabs:end -->

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

<!-- tabs:start -->
```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
```
<!-- tabs:end -->

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

<!-- tabs:start -->
```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
```
<!-- tabs:end -->

```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"
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
<script type="module">
  const getToken = async () => (await fetch("/api/gamecoin-token").then((r) => r.json())).token;

  const gc = await GameCoin.init({
    publishableKey: "gc_pk_test_…",
    playerToken: await getToken(),   // the SDK creates no player: it plays as this one
    onTokenExpired: getToken,        // called once when the token (valid 1 hour) is rejected
  });

  console.log(gc.balance("gems"));   // 70: the balance your server just set
  await gc.checkout("gems-500");     // same as path A from here
</script>
```

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.

<!-- tabs:start -->
```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
```
<!-- tabs:end -->

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