# Client API

> The API a browser or a game client calls with a publishable key and a player token, to read, spend, buy and pay without ever crediting a player.

The client API is the part of GameCoin that your game client calls directly, from a browser or from the game itself. It uses a **publishable key**, which is safe to ship in a client, and for everything about one player it uses a **player token**. It can read the catalog, read the player's wallet and inventory, spend, buy an item with a currency, consume an item and pay for a pack. It can never credit anything, with one exception: the player can **redeem a gift code**, which gives free coins and items from a code that your studio issued. All its routes live under `/client`.

In a browser you will usually not call these routes yourself: the [browser SDK](/docs/sdk/browser) does. This page documents the routes, for the SDK, for game clients that are not browsers, and for debugging. The [guide for a game without a backend](/docs/guides/no-backend-game) and [the one for a game with a backend](/docs/guides/game-with-backend) show the two usual setups.

## Setup for the examples

Every example has two tabs: curl, for the HTTP request, and Browser, for the same thing with the [browser SDK](/docs/sdk/browser). In curl you need your publishable key, and a player token for the routes that act on a player. In the Browser tab, `gc` is the client returned by `GameCoin.init`; `playerToken` is a token your backend got from [Issue a player token](/docs/api/players#issue-a-player-token). Without a `playerToken`, the SDK creates an anonymous player on its own: see [Create an anonymous player](#create-an-anonymous-player).

<!-- tabs:start -->
```bash tab="curl"
export GAMECOIN_PUBLISHABLE_KEY="gc_pk_test_…"   # a publishable test key: safe in a browser
export GAMECOIN_PLAYER_TOKEN="gc_pt_…"           # a player token, issued by your server for ext:user-42
```
```html tab="Browser"
<script src="https://gamecoin.apilow.com/sdk/gamecoin.js"></script>
<script type="module">
  const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…", playerToken }); // playerToken: from your backend

  // ... the example you want to run goes here
</script>
```
<!-- tabs:end -->

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.

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

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

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

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

<!-- tabs:start -->
```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": "<player secret>"}'
```
```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
```
<!-- tabs:end -->

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

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

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

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

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

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

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

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

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

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

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

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

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