# Browser SDK

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

  gc.on("change", ({ wallet, inventory }) => console.log(wallet.balances, inventory));
  console.log(gc.balance("gems"));
</script>
```

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

`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 `<script src>` | Where GameCoin runs. Required when the SDK was not loaded from a script tag. |
| `storage` | `{ getItem, setItem, removeItem }` or `null` | `localStorage`, or memory | Where the anonymous player is remembered. `null` remembers nothing. |
| `displayName` | string | none | The name given to a new anonymous player |
| `debug` | boolean | `false` | Log requests and retries with `console.debug` |
| `maxAttempts` | number | `3` | Attempts per request on network errors and `5xx` |
| `retryDelayMs` | number | `400` | The first wait between attempts, doubled each time |
| `timeoutMs` | number | `20000` | The timeout of one request |
| `fetch`, `window` | functions | the globals | Injection points for tests and non-browser environments |

`init` fails with a [`GameCoinError`](#errors) when the key is missing, is a secret key, is not a publishable key, or when `baseUrl` is needed and absent. These errors are raised before any request. Anonymous players that are turned off for the game make `init` fail with `FORBIDDEN`.

A page that comes back from a redirect checkout also has `?order=…&status=…` in its address. `init` reads that order, removes the parameters from the address, and exposes the order as `gc.returnedOrder`.

## The client

```javascript
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
```

### Properties

| Property | Type | Meaning |
|---|---|---|
| `gc.player` | `Player` | The current player: `{ id, externalId, kind, displayName, country, blocked, createdAt }`. A copy: changing it changes nothing. |
| `gc.wallet` | `Wallet` | A copy of the cached wallet: `{ playerId, balances: [{ currency, paid, bonus, total }] }` |
| `gc.inventory` | `InventoryItem[]` | A copy of the cached inventory: `[{ sku, quantity, updatedAt }]`, owned items only |
| `gc.returnedOrder` | `Order` or `null` | The order read when the player came back from a redirect checkout |

### Methods at a glance

| Call | Returns | Needs the server |
|---|---|---|
| `getCatalog()` | `{ currencies, items, packs, prelaunch, launchAt }` | yes |
| `getWallet()` | `Wallet` | yes |
| `getInventory()` | `InventoryItem[]` | yes |
| `refresh()` | `{ wallet, inventory }` | yes |
| `balance(currency)` | number | no, cached |
| `owned(sku)` | number | no, cached |
| `spend(currency, amount, reason?, options?)` | `{ entry, balance }` | yes |
| `buyItem(sku, quantity = 1, options?)` | `{ entry, balance, item }` | yes |
| `consume(sku, quantity = 1, options?)` | `{ item }` | yes |
| `checkout(packSku, options?)` | `Order` | yes |
| `redeemCode(code, options?)` | `{ grants, items, skipped }` | yes |
| `openShop(options)` | `{ close() }` | yes |
| `requestEmailCode(email, options?)` | `{ sent, expiresInSeconds }` | yes |
| `verifyEmailCode(email, code, options?)` | `{ player, merged, switched }` | yes |
| `signInWithEmail(email, options?)` | `{ sent, expiresInSeconds }` | yes |
| `verifySignIn(email, code, options?)` | `{ player, merged, switched }` | yes |
| `on(event, handler)` | an unsubscribe function | no |
| `off(event, handler)` | nothing | no |
| `setPlayerToken(token)` | nothing | no |

Every call that talks to GameCoin returns a promise and fails with a [`GameCoinError`](#errors). One function does not need a client or the server: `GameCoin.packStatus(pack, now?)` tells whether a pack of the catalog is scheduled, on sale, sold out or ended, see [Pack status](#pack-status).

## Reading

### getCatalog()

```text
gc.getCatalog() → Promise<{ currencies, items, packs, prelaunch, launchAt }>
```

The active currencies, items and packs of your game. It needs only the publishable key, so it works before a player exists. Prices of packs are in euro cents, VAT included.

```javascript
const { currencies, items, packs } = await gc.getCatalog();
console.log(packs.map((p) => `${p.name}: ${(p.priceCents / 100).toFixed(2)} €`)); // [ 'Starter pack: 0.99 €', 'Bag of gems: 4.99 €' ]
```

The catalog is what the [API](/docs/api/catalog) answers, with one change: **the dates are `Date` objects**, not strings. They are the ones of the [founder packs](/docs/guides/founder-packs) and of the launch of your game:

| Field | Type | Meaning |
|---|---|---|
| `pack.availability` | object or `null` | `null` for a pack that is always on sale, without limit. |
| `pack.availability.startsAt`, `endsAt` | `Date` or `null` | The sale window: it starts at `startsAt` (included) and ends at `endsAt` (excluded). `null` is no bound. |
| `pack.availability.remaining` | number or `null` | The units that can still be bought, or `null` for an unlimited stock. |
| `pack.availability.founder` | boolean | `true` for a founder pack. |
| `prelaunch` | boolean | `true` while your game is not launched: show pre-orders. |
| `launchAt` | `Date` or `null` | The launch date you set in the game settings. |

The catalog also lists the packs that are scheduled, ended or sold out: you decide what to show. A date that cannot be read becomes `null`, never an invalid `Date`.

### Pack status

```text
GameCoin.packStatus(pack, now = new Date()) → "scheduled" | "on_sale" | "sold_out" | "ended"
```

A pure function: it makes no request and needs no client. It applies the rules of the server to a pack of `getCatalog()` (or of the raw API answer: ISO strings are accepted). `now` is a `Date`, a timestamp in milliseconds or an ISO string; a value that is none of these fails with `VALIDATION_FAILED`.

| Result | When |
|---|---|
| `"scheduled"` | `now` is before `startsAt` |
| `"ended"` | `now` is at or after `endsAt` |
| `"sold_out"` | The window is open and `remaining` is `0` |
| `"on_sale"` | Everything else, including a pack with `availability: null` |

The window comes first: a pack that has ended is `"ended"` even when it is also sold out, as on the server.

```javascript
const catalog = await gc.getCatalog();
for (const pack of catalog.packs) {
  const status = GameCoin.packStatus(pack);
  if (status === "scheduled") showSoon(pack, pack.availability.startsAt);
  else if (status === "on_sale") showBuyButton(pack, pack.availability?.remaining); // "312 left", or null
  else showGreyedOut(pack, status); // "sold_out" or "ended"
}
if (catalog.prelaunch && catalog.launchAt) showBanner("Launch on " + catalog.launchAt.toLocaleDateString());
```

> [!NOTE]
> This is a snapshot, computed with the clock of the player's device. The server decides when the order is created: it answers `PACK_NOT_AVAILABLE` or `PACK_SOLD_OUT` to a checkout that comes too early, too late or too slow. `remaining` can also go **up** again, when a pending order expires or an order is refunded. Read the catalog again before you rely on it.

### getWallet(), getInventory() and refresh()

```text
gc.getWallet()     → Promise<Wallet>
gc.getInventory()  → Promise<InventoryItem[]>
gc.refresh()       → Promise<{ wallet, inventory }>
```

Each one reads the server (one request, `GET /client/me`) and updates the cache. If the wallet or the inventory changed, a `change` event fires with `reason: "refresh"`. Use `refresh()` after your server changed something, for example a reward it granted.

```javascript
await fetch("/api/level-complete", { method: "POST" }); // your server grants gems
await gc.refresh();
console.log(gc.balance("gems"));
```

### balance() and owned()

```text
gc.balance(currency) → number
gc.owned(sku)        → number
```

Synchronous reads of the cache: `balance` is the `total` of a currency, `owned` is the quantity of an item. Both return `0` for a code the SDK does not know. They make no request, so call them as often as you draw the screen.

## Changing

The calls below take a last argument `options`. Its main key is `idempotencyKey`: see [idempotency](#idempotency-and-retries).

### spend()

```text
gc.spend(currency, amount, reason?, options?) → Promise<{ entry, balance }>
```

Spends currency with no item: "continue?", a skip, an entry fee.

| Argument | Meaning |
|---|---|
| `currency` | The code of the currency, for example `"gems"` |
| `amount` | A positive whole number |
| `reason` | Free text kept in the ledger (up to 500 characters). Optional. |

```javascript
const { entry, balance } = await gc.spend("gems", 5, "continue");
console.log(entry.type, balance.total); // spend 95
```

| Error | Cause |
|---|---|
| `INSUFFICIENT_FUNDS` | The wallet is too low. `details` has `required` and `available`. |
| `NOT_FOUND` | Unknown currency |
| `VALIDATION_FAILED` | `amount` is not a positive whole number (raised before any request) |
| `PLAYER_BLOCKED` | The player is blocked |

### buyItem()

```text
gc.buyItem(sku, quantity = 1, options?) → Promise<{ entry, balance, item }>
```

Buys an item with its own price, in its own currency. The wallet is debited and the item added in one step. Only items marked « Achetable depuis le jeu (API cliente) » in the dashboard can be bought from the browser.

```javascript
const { balance, item } = await gc.buyItem("potion", 2);
console.log(balance.total, item.quantity); // 55 2
```

| Error | Cause |
|---|---|
| `INSUFFICIENT_FUNDS` | The player cannot afford it |
| `LIMIT_REACHED` | The item's `maxOwned` would be exceeded. `details` has `maxOwned` and `owned`. |
| `FORBIDDEN` | The item is not buyable from the game |
| `NOT_FOUND` | Unknown item |

### consume()

```text
gc.consume(sku, quantity = 1, options?) → Promise<{ item }>
```

Uses up units of a consumable item. The returned `item` is the item as it now stands, even when it reaches `quantity: 0`.

```javascript
const { item } = await gc.consume("potion");
console.log(item.quantity); // 1
```

| Error | Cause |
|---|---|
| `INSUFFICIENT_FUNDS` | The player owns fewer than `quantity`. `details` has `required` and `available`. |
| `CONFLICT` | The item is durable: it cannot be consumed |
| `NOT_FOUND` | Unknown item |

### redeemCode()

```text
gc.redeemCode(code, options?) → Promise<{ grants, items, skipped }>
```

Redeems a [gift code](/docs/guides/codes#gift-codes) that your studio created, for the current player. It is the **only call of the browser SDK that adds coins**, and it is safe because the value is decided by your studio, not by the browser: the code is random, it has a limited number of uses, a player can use it **once**, and it gives **free coins** (never paid ones) and items. See [Codes API](/docs/api/codes#redeem-a-gift-code).

```javascript
try {
  const { grants, items, skipped } = await gc.redeemCode(input.value);
  showMessage(`You received ${grants.map((g) => g.amount + " " + g.currency).join(", ")}!`);
} catch (error) {
  if (!(error instanceof GameCoin.GameCoinError)) throw error;
  if (error.details.fieldErrors?.code) showMessage("This code is not valid.");
  else if (error.code === "LIMIT_REACHED") showMessage("You already have this reward.");
  else if (error.code === "RATE_LIMITED") showMessage("Too many tries. Wait a little.");
  else throw error;
}
```

| What | Meaning |
|---|---|
| `code` | The code **as the player typed it**, a non-empty string. The SDK does not trim it or change its case: the API ignores case, spaces and dashes. Anything else fails at once with `VALIDATION_FAILED` (`status: 0`, no request). |
| `grants` | One line per currency credited: `{ currency, amount, balance }`, where `balance` is the balance afterwards. Always free coins. |
| `items` | What was added to the inventory: `[{ sku, quantity }]` |
| `skipped` | The items the player could not receive because they already own the most they can: `[{ sku, quantity }]`. The rest is still given. |

The cached balances take the value of the answer, the cached quantities of the items go up by what was added, and a [`change`](#change) event fires with `reason: "code"`. If you need the exact inventory after a long time, call `refresh()`.

The call is idempotent like the others: it sends a key (yours, or a random one), replays on a network error with the **same** key, and a replay credits **nothing twice**. A code the player already redeemed is a refusal like any other: typing it again, with a new key, gives `CODE_INVALID`.

| Error | Cause |
|---|---|
| `VALIDATION_FAILED` | `details.fieldErrors.code` is `["CODE_INVALID"]`: the code is unknown, malformed, expired, used up, disabled, already used by this player, or a creator code. This is the API's own error, **passed on as it is**: the SDK never replays it nor rewords it, and nothing tells which of these it is. Show one message. |
| `LIMIT_REACHED` | The player already owns the most of every item of the code, and it gives nothing else. The code is **not** used up. |
| `PLAYER_BLOCKED` | The player is blocked |
| `RATE_LIMITED` | 10 tries per 15 minutes per player, right or wrong. `details.retryAfter` gives the seconds. |

**With a `playerToken` from your backend**, the call works too: the token is the player's, so the code is redeemed for them and the cache follows. If your backend must decide who may redeem (a login, a quota of your own), do not offer the code field in the browser: call the [server route](/docs/api/codes) from your server.

### checkout()

```text
gc.checkout(packSku, options?) → Promise<Order>
```

Sells a pack for euros. It opens the hosted payment page and resolves when the order is over. **Call it straight from a click**: the SDK opens the payment window synchronously, before any request, and browsers block windows that are not opened by a user action.

```javascript
document.getElementById("buy").onclick = async () => {
  const order = await gc.checkout("gems-500");
  console.log(order.status); // "fulfilled", "failed" or "pending"
};
```

| Option | Meaning |
|---|---|
| `mode` | `"redirect"` sends the current tab to the payment page instead of opening a window |
| `successUrl`, `cancelUrl` | Where the player returns after paying or declining. They must have the origin of the page. By default, the current address. |
| `idempotencyKey` | Your own idempotency key |
| `locale` | `"fr"`, `"en"` or `"nl"`: the language of the payment page, the receipt and the e-mails of the order. By default, the language of the page, see [Language](#language). |
| `creatorCode` | The [creator code](/docs/guides/codes#creator-codes) the buyer typed, for example `"LEAPLAY"`. See [Creator code](#creator-code). |

What it resolves with, in a window:

| `order.status` | Meaning |
|---|---|
| `fulfilled` | The player paid. The pack is delivered and the cached wallet is already refreshed. |
| `failed` | The payment was declined |
| `pending` | The player closed the window without paying |
| `expired` and others | The order ended another way. Read `order.status`. |

The SDK listens to the window's `postMessage` (only from the GameCoin origin) and reads the order from the API every 2 seconds. A message is only a signal: the status always comes from the API, never from the message.

**Redirect.** When the browser blocks the window, or with `mode: "redirect"`, the current tab goes to the payment page and returns to the current address. The promise then resolves at once with the pending order plus `redirected: true`, since the page is about to leave. On the way back, `init()` reads the order, removes `?order=&status=` from the address, exposes it as `gc.returnedOrder`, and fires a `change` event with `reason: "return"` and the order.

```javascript
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
gc.on("change", ({ reason, order }) => {
  if (reason === "return") console.log("back from payment:", order.status);
});
if (gc.returnedOrder) console.log(gc.returnedOrder.status);
```

Only one checkout can run at a time: a second call while the first runs fails with `CONFLICT`.

| Error | Cause |
|---|---|
| `NOT_FOUND` | Unknown pack, or a pack that is still a draft |
| `LIMIT_REACHED` | The pack's `maxPerPlayer` is reached |
| `PAYMENT_FAILED` | There is no payment provider for this environment |
| `CONFLICT` | A checkout is already in progress |
| `PACK_NOT_AVAILABLE` | The pack is outside its sale window. `details.startsAt` and `details.endsAt` hold the dates (ISO 8601 strings, as the API sends them, or `null`). See [Pack status](#pack-status). |
| `PACK_SOLD_OUT` | The pack has no unit left |
| `VALIDATION_FAILED` | `packSku` is empty, a return URL does not have the origin of the page, `creatorCode` is not a non-empty string, or the creator code is refused (see below) |

See [Selling packs](/docs/guides/selling-packs) for VAT, return URLs and the life of an order.

### Creator code

A buyer who has a [creator code](/docs/guides/codes#creator-codes) (a streamer's code) types it at checkout. Pass it as it was typed:

```javascript
const order = await gc.checkout("gems-500", { creatorCode: input.value || undefined });
console.log(order.creatorCode); // { code: "LEAPLAY", creatorName: "Léa Play" }, or null without a code
```

- It goes in the field `creatorCode` of the order. The SDK does not change it (the API ignores the case). Anything but a non-empty string fails at once with `VALIDATION_FAILED`, before any window or request: pass `undefined` when the buyer typed nothing.
- The `Order` that `checkout()` resolves with (and `gc.returnedOrder`) has `creatorCode`: `{ code, creatorName }`, or `null`. It never carries the rates of the creator.
- A code that cannot be used (unknown, disabled, not valid for this pack, or the creator's own player) is refused by the API with `VALIDATION_FAILED` and `details.fieldErrors.creatorCode` equal to `["CODE_INVALID"]`, one answer for every cause. The SDK passes that error on as it is, never replays it, and closes the payment window it had opened: no order was created. Tell the player the code is not valid, and let them try again or buy without it.
- The bonus coins that the code gives are added by GameCoin when the order is delivered, with the pack, so the wallet that `checkout()` refreshes already has them.

## Open the shop

```text
gc.openShop(options) → Promise<{ close() }>
```

Opens the [shop widget](/docs/sdk/widget) over your game, **as the player of this SDK**. The SDK holds the player token, so you do not have to make one, keep one or pass one around: that is the way to use the widget in a game without a server.

```javascript
document.getElementById("shop").onclick = async () => {
  const shop = await gc.openShop({ gameSlug: "my-game", locale: "en", theme: "dark" });
  // later, if you need to: shop.close();
};

gc.on("change", ({ wallet, reason }) => {
  if (reason === "shop") draw(wallet); // the player bought something, or typed a code, in the shop
});
```

| Option | Type | Default | Meaning |
|---|---|---|---|
| `gameSlug` | string | required | The slug of your game (lowercase letters, digits and dashes). The shop must be turned on, and the origin of your page must be in the [allowed origins](/docs/sdk/widget#allowed-origins) of the game. |
| `locale` | `"fr"`, `"en"` or `"nl"` | The language of the page | The language of the shop |
| `theme` | `"light"` or `"dark"` | `"light"` | The colors of the shop |
| `title` | string | « Shop » in the language | The accessible name of the dialog |
| `loadTimeoutMs` | number | `15000` | How long to wait for the shop before it offers « Try again » |

Wrong options fail at once with `VALIDATION_FAILED` (`status: 0`), before anything is loaded. The other options of the widget (`baseUrl`, `token`) are not options here: the SDK uses its own base and its own token.

What the SDK does, in this order:

1. **Loads the widget once.** If `window.GameCoinShop` does not exist, it adds one `<script src>` for `gamecoin-widget.js`, from the same place as the SDK (`<baseUrl>/sdk/gamecoin-widget.js`), and waits for it. It never uses `eval`. The script is added once per page, however many times you call `openShop()`. If it cannot be loaded (network, or a Content-Security-Policy whose `script-src` does not allow your GameCoin host), the call fails with `NETWORK_ERROR`, and the next call tries again.
2. **Makes sure the token is fresh.** A token that has less than 5 minutes left is renewed first, the way the SDK renews it after a `401`. See [The token of a backend game](#the-token-of-a-backend-game) for your own tokens.
3. **Opens the shop** with `GameCoinShop.open`, and gives it the token.

> [!NOTE]
> **The token never reaches your code.** The SDK hands it to the widget and to nobody else: it is not in the value that `openShop()` returns, in an event, in an error message or in the `debug` log. Call `openShop()` from a click or a tap.

### What the shop tells your game

The events of the widget are relayed on the client, with `gc.on(event, handler)`:

| Event | Payload | When |
|---|---|---|
| `"shop:purchased"` | `{ orderId, status }` | An order of this player ended in the shop: `fulfilled`, `failed`, `canceled`, `expired`… The SDK has **already read the wallet again**, so `gc.balance()` is up to date when your handler runs. |
| `"shop:close"` | `{}` | The shop was closed: button, Escape, a click on the backdrop, `close()`, or another `openShop()`. |
| `"shop:error"` | `{ code, message }` | `SESSION_EXPIRED` (the token was refused: call `openShop()` again) or `LOAD_FAILED` (the shop did not answer in time: wrong game slug, shop turned off, origin not allowed). |

Before `"shop:purchased"`, the SDK reads the player (`GET /client/me`) and, if the wallet or the inventory changed, fires [`change`](#change) with `reason: "shop"`. It does the same when the shop shows a balance that is not the one in the cache, which is what happens when the player types a [gift code](/docs/guides/codes) in the shop. The balance that the widget announces is only a signal: the numbers always come from the API.

> [!WARNING]
> `"shop:purchased"` comes from a browser, so anyone can send it. For anything your game gives **because of a purchase** (a reward, an unlock), read the order on your server (`GET /orders/{order}`, or the `order.fulfilled` [webhook](/docs/guides/webhooks)) first. The coins and the items of the pack are credited by GameCoin itself, whatever the event says. Events stop when the shop closes: if the player pays in the payment window and closes the shop at once, call `refresh()`.

Only one shop is open at a time: `openShop()` while another is open closes it first (`"shop:close"` fires once for it), and the handle of the old one no longer closes anything. A second `openShop()` while the first is still opening fails with `CONFLICT`.

### The token of a backend game

With a `playerToken` from your server, `openShop()` opens the shop **as that player, with that token**: nothing is created and nothing is refused. The SDK cannot read the end of a token that your server made, so it first reads the player (one request). If the token is refused, it goes through `onTokenExpired` like any other call, and the shop opens with the new token. Without `onTokenExpired`, the call fails with `UNAUTHENTICATED` and the shop does not open. A token that is accepted but about to end is not renewed: the shop swaps it for its own session of one hour as soon as it loads. Pass `onTokenExpired`, as for every backend game.

## Language

Three calls send e-mails or show a page to the player: `checkout()` (payment page, receipt and e-mails of the order), `requestEmailCode()` and `signInWithEmail()` (the e-mail with the code). They accept the same `locale` option, `"fr"`, `"en"` or `"nl"`:

```javascript
await gc.checkout("gems-500", { locale: "nl" });
await gc.requestEmailCode("alice@example.com", { locale: "en" });
```

Without it, the SDK uses the language of your page, the `lang` attribute of `<html>`, when it is one of the three (`"fr-BE"` counts as `"fr"`). For any other language, or none, it sends nothing and the server follows the browser's `Accept-Language`, then French. A `locale` that is not one of the three is not sent either, because the API ignores it. A `locale` that is not a string fails with `VALIDATION_FAILED`, before any request or any window.

## E-mail sign-in

An anonymous player lives in the browser's storage. With an e-mail address and a six-digit code (no password) they can find the same player, with their coins and their items, on another device or after clearing the storage. The [Sign in players with their e-mail](/docs/guides/player-email-sign-in) guide explains the idea and the routes; this section is the SDK.

The four methods are for anonymous players. With a `playerToken` from your backend they fail at once with `FORBIDDEN` (`status: 0`, no request): in that mode your backend owns the identity of the player. All four are idempotent like the other calls, and take a last `options` argument with `idempotencyKey`, and with `locale` for the two that send a code.

| You are in this situation | Send the code | Check it |
|---|---|---|
| The player is playing and wants to **keep** their progress, or move to the player they already linked | `requestEmailCode(email, options?)` | `verifyEmailCode(email, code, options?)` |
| The player wants to **get back** the player of an address (new device, empty storage) | `signInWithEmail(email, options?)` | `verifySignIn(email, code, options?)` |

```javascript
// 1. The player types their address; GameCoin e-mails a 6-digit code.
await gc.requestEmailCode("alice@example.com", { locale: "en" }); // { sent: true, expiresInSeconds: 600 }

// 2. The player types the code. Keep it a string: "042917" starts with a zero.
try {
  const { player, merged, switched } = await gc.verifyEmailCode("alice@example.com", "042917");
  if (switched) console.log("welcome back, you are now player", player.id);
} catch (error) {
  if (error instanceof GameCoin.GameCoinError && error.details.fieldErrors?.code) showMessage("That code is not valid. Ask for a new one.");
  else throw error;
}
```

Sending a code always answers `{ sent: true, expiresInSeconds }`, whether the address is known or not.

### What the check returns and keeps

`verifyEmailCode` and `verifySignIn` resolve with `{ player, merged, switched }`:

| Field | Meaning |
|---|---|
| `player` | The player the session now belongs to (a copy) |
| `merged` | `true` when the address was already linked to **another** player and the session switched to it (`verifyEmailCode` only) |
| `switched` | `true` when the player is not the one this device had before the call. This is the flag to read: it is `true` after every `verifySignIn` that finds another player. |

The new token and the new `playerSecret` that the API returns never reach your code: the SDK keeps them, the way it keeps the token and the secret of an anonymous player. The token replaces the current one. A new secret replaces the remembered `{ playerId, playerSecret }` in the storage, so the next visits and every token renewal act as the player you got back. If the player changed, the SDK reads the wallet and the inventory of the new player and fires [`change`](#change) with `reason: "email"` (after `verifyEmailCode`) or `"sign-in"` (after `verifySignIn`), even when the numbers are the same: `gc.player` is not the same player any more. If that read fails, the cache is emptied rather than left showing the previous player's coins, and the next `refresh()` fills it again.

> [!WARNING]
> GameCoin never merges two wallets. When the session switches to another player, the player that this device had before is left as it was on the server, with its coins and its items, but **this device forgets its secret**: nothing here can reach it any more. Tell the player before they switch.

### Wrong codes

A wrong, expired, used up, already used or never requested code always gives the same error, so that nobody learns anything from it: a `GameCoinError` with `code: "VALIDATION_FAILED"`, `status: 400` and `details.fieldErrors.code` equal to `["CODE_INVALID"]`. The session and the storage do not change. Five wrong tries kill a code: ask for a new one with `requestEmailCode` or `signInWithEmail`. `code` must be a string: a number fails locally with `VALIDATION_FAILED`, because `42917` is not `"042917"`. Spaces and dashes in the string are ignored by the API.

| Error | Cause |
|---|---|
| `VALIDATION_FAILED` | Invalid address, or `details.fieldErrors.code = ["CODE_INVALID"]` for a code that is not accepted |
| `NOT_FOUND` | `verifySignIn` with a valid code, but no player is linked to that address |
| `FORBIDDEN` | The player is not anonymous (a `playerToken` was given) |
| `PLAYER_BLOCKED` | The player that the address belongs to is blocked |
| `RATE_LIMITED` | Too many codes asked. `details.retryAfter` gives the seconds when the wait is over 10 seconds. |

## Events

### change

The SDK keeps a copy of the wallet and the inventory. It fires `change` whenever that copy actually changes.

```text
gc.on("change", handler) → unsubscribe function
gc.off("change", handler)
```

`handler` receives `{ wallet, inventory, reason, order? }`. `wallet` and `inventory` are copies. `reason` says what caused the change:

| `reason` | Fired after |
|---|---|
| `"spend"` | `spend()` |
| `"purchase"` | `buyItem()` |
| `"consume"` | `consume()` |
| `"checkout"` | A fulfilled `checkout()` refreshed the wallet |
| `"code"` | `redeemCode()` credited a gift code |
| `"shop"` | The shop widget opened by `openShop()` reported a purchase or a new balance, and the SDK read the wallet again |
| `"refresh"` | `refresh()`, `getWallet()` or `getInventory()` found something different |
| `"return"` | The player came back from a redirect checkout (with `order`) |
| `"email"` | `verifyEmailCode()` switched the session to another player |
| `"sign-in"` | `verifySignIn()` switched the session to another player |

An error in your handler does not break the SDK or the other handlers. It is reported as an uncaught error. A call that fails changes nothing and fires nothing. `on` returns a function that removes the handler:

```javascript
const stop = gc.on("change", ({ wallet }) => draw(wallet));
// later
stop();
```

### Shop events

`gc.on` also takes `"shop:purchased"`, `"shop:close"` and `"shop:error"`, the events of the shop opened by [`openShop()`](#open-the-shop). They have no wallet in their payload: read it with `gc.wallet`, or wait for `change`. Any other event name fails with `VALIDATION_FAILED`.

## Idempotency and retries

Every call that changes something (`spend`, `buyItem`, `consume`, `checkout`, `redeemCode` and the e-mail calls) sends an [idempotency key](/docs/concepts/idempotency). By default the SDK makes a random one **per call** and reuses it for every automatic replay of that call. A retry therefore cannot count twice.

To make a call safe across a page reload, give your own key as the last argument:

```javascript
await gc.spend("gems", 5, "level-3-entry", { idempotencyKey: "level-3-entry-2026-10-05" });
```

The SDK replays a request, with the same key, in these cases:

| Situation | What the SDK does |
|---|---|
| Network error, or a `5xx` | Waits `retryDelayMs`, doubled each time, and tries again, up to `maxAttempts` (3). Then raises `NETWORK_ERROR`, or the error of the API. |
| `429 RATE_LIMITED` | Waits for `Retry-After` and tries again, if the wait is 10 seconds or less. For a longer wait it raises `RATE_LIMITED` with the seconds in `error.details.retryAfter`. |
| `401` (token refused) | Renews the token once and replays. See [expired tokens](#expired-tokens). |
| Any other `4xx` | Raises the error, never replays |

Each attempt has a timeout of `timeoutMs` (20 seconds by default).

## Expired tokens

A player token lasts one hour. When a request is refused with `401`:

- **Anonymous player.** The SDK exchanges the stored secret for a new token by itself, then replays the request with the same idempotency key. You do nothing.
- **Your player (`playerToken`).** The SDK calls `onTokenExpired` once. Return a new token from your server, and the SDK replays the request.

```javascript
const getToken = async () => (await fetch("/api/gamecoin-token", { method: "POST" })).json().then((r) => r.token);

const gc = await GameCoin.init({
  publishableKey: "gc_pk_test_…",
  playerToken: await getToken(),
  onTokenExpired: getToken,
});
```

Without `onTokenExpired`, a refused token is raised as `UNAUTHENTICATED` (and the SDK never creates a player in its place). If the new token is refused too, the SDK stops: it does not loop. Several requests that fail together cause a single renewal.

To swap the token yourself, for example after the player logged in again, use `gc.setPlayerToken(token)`.

## Errors

Every failure is a `GameCoinError`, available as `GameCoin.GameCoinError` for `instanceof`.

| Field | Content |
|---|---|
| `name` | `"GameCoinError"` |
| `code` | An API code (`INSUFFICIENT_FUNDS`, `LIMIT_REACHED`, `PLAYER_BLOCKED`, `UNAUTHENTICATED`, `RATE_LIMITED`…) or `NETWORK_ERROR` |
| `status` | The HTTP status, `0` when no answer was received or when the SDK refused the call before any request |
| `message` | A sentence for developers. Do not show it to players. |
| `details` | The `details` of the API (`required` and `available` for `INSUFFICIENT_FUNDS`), `{}` otherwise |

```javascript
try {
  await gc.buyItem("fire-sword");
} catch (error) {
  if (!(error instanceof GameCoin.GameCoinError)) throw error;
  switch (error.code) {
    case "INSUFFICIENT_FUNDS":
      showShop(error.details.required - error.details.available);
      break;
    case "LIMIT_REACHED":
      showMessage("You already own it.");
      break;
    case "NETWORK_ERROR":
      showMessage("No connection. Try again.");
      break;
    default:
      throw error;
  }
}
```

Arguments the SDK can judge itself (an empty `sku`, an `amount` of 0 or 1.5) fail with `VALIDATION_FAILED` and `status: 0`, with no request. A `NETWORK_ERROR` after the last attempt means the write may or may not have happened: read the wallet with `refresh()`, or pass your own `idempotencyKey` so that a retry cannot count twice. All codes are in [Errors](/docs/concepts/errors).

## The player and the browser's storage

On an anonymous player's first visit, the SDK creates the player and keeps `{ playerId, playerSecret }` in the browser's storage, under a name that includes your publishable key. The next visits use that secret to get a token. Two different publishable keys give two different players in the same browser.

- If the stored player is refused (`401`), the SDK creates a new one. Any other error creates nothing.
- If `localStorage` is not available (private mode, blocked data, a sandboxed frame), the SDK uses memory: the game works, and the player is new on the next visit.
- `storage: null` keeps nothing at all.
- To use your own store (an engine's save system), pass `{ getItem, setItem, removeItem }`.

The secret never leaves that browser, and it cannot be recovered. Clearing the data makes a new player, unless the player linked an e-mail address: they can then get the same player back with [`signInWithEmail()`](#e-mail-sign-in).

## Browsers and limits

- ES2020, `fetch` and `AbortController`. Every modern browser has them.
- `crypto.randomUUID()` only exists on secure pages (`https:`, `localhost`); elsewhere the SDK makes its keys from random bytes.
- The client API answers only the origins listed in the game's settings (any origin in `test` while the list is empty). The SDK never sends cookies.
- A page served from `file://` has no real origin: pass `baseUrl`. For anything that involves a payment, serve the game over `http://localhost` or `https://`.

## Where next

- [A game without a backend](/docs/guides/no-backend-game): the SDK from first line to a working page.
- [A game with a backend](/docs/guides/game-with-backend): the SDK with a player token.
- [Selling packs](/docs/guides/selling-packs)
- [Gift codes and creator codes](/docs/guides/codes) and [Founder packs](/docs/guides/founder-packs)
- [Shop widget](/docs/sdk/widget): the shop inside your web game.
