# Wallet API

> Read a player's balances, grant them free currency and spend it from your backend with the server API.

A player has one balance per currency, split in two parts: `paid`, bought with real money, and `bonus`, granted or earned for free. This page reads a wallet, credits free currency and debits currency; every change is recorded in the [ledger](/docs/api/ledger). Selling a currency for real money is a [checkout](/docs/api/checkouts), not a grant. The examples on this page continue from the [setup](/docs/api#setup-for-the-examples).

## Get a wallet

Returns every balance of a player. Use it to show a player their coins, or to check a balance before you decide something.

```endpoint
GET /players/{player}/wallet
```

**Authentication:** secret key. **Idempotency:** not needed.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `player` | string | A GameCoin player id, or `ext:<your id>` URL-encoded once. |

<!-- tabs:start -->
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const wallet = await gamecoin.wallet.get(player);
for (const { currency, paid, bonus, total } of wallet.balances) console.log(currency, paid, bonus, total);
```
```python tab="Python"
wallet = gamecoin.wallet.get(player)
for balance in wallet.balances:
    print(balance.currency, balance.paid, balance.bonus, balance.total)
```
```php tab="PHP"
$wallet = $gamecoin->wallet->get($player);
foreach ($wallet->balances as $balance) {
    echo "{$balance->currency}: paid {$balance->paid}, bonus {$balance->bonus}, total {$balance->total}\n";
}
```
```go tab="Go"
wallet, err := client.Wallet.Get(ctx, player)
check(err)
for _, b := range wallet.Balances {
	fmt.Println(b.Currency, b.Paid, b.Bonus, b.Total)
}
```
```csharp tab="C#"
var wallet = await gamecoin.Wallet.GetAsync(player);
foreach (var balance in wallet.Balances) Console.WriteLine($"{balance.Currency} {balance.Paid} {balance.Bonus} {balance.Total}");
```
<!-- tabs:end -->

**Response** `200 OK`: a [Wallet](/docs/api/objects#wallet).

```json
{
  "playerId": "665f1c2e8a3b4d5e6f708192",
  "balances": [
    { "currency": "gems", "paid": 0, "bonus": 70, "total": 70 },
    { "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
  ]
}
```

**Errors**

| Status | Code | When | What to do |
|---|---|---|---|
| 400 | `VALIDATION_FAILED` | The player reference is malformed (`PLAYER_REF_INVALID`) | Encode the `ext:` reference exactly once. |
| 404 | `NOT_FOUND` | The player does not exist | An `ext:` player exists after its first write; before that, treat the wallet as empty. |

**Notes**

- `balances` has **one line per active currency, zeros included**: here `gold` is there although the player never received any. A currency that is archived disappears from the list.
- `total` is `paid + bonus`. Only `paid` comes from real money, which is what a refund takes back.
- A balance can be **negative** after a refund of coins the player had already spent (the default of a game; the game setting « Après un remboursement » in the dashboard, which is in French for now, can stop balances at zero instead). The amount spent shows as a debt on `paid`, and the next credits repay it first. A spend never makes a balance negative.

## Grant currency

Credits free currency to a player: a quest reward, a daily gift, a compensation. It is for your backend only: the client API cannot credit a player, except through a [gift code](/docs/api/codes#redeem-a-gift-code) that you created.

```endpoint
POST /players/{player}/wallet/grant
```

**Authentication:** secret key. **Idempotency:** required (`Idempotency-Key` header).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `player` | string | A GameCoin player id, or `ext:<your id>` URL-encoded once. An unknown `ext:` player is created. |

**Body**

| Field | Type | Required | Rules |
|---|---|---|---|
| `currency` | string | yes | A currency code of the catalog: 2 to 24 characters, a lowercase letter then lowercase letters, digits or `_` (`gems`, `gold`). |
| `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. |
| `metadata` | object | no | Free `string` to `string` pairs recorded in the ledger: at most 20 entries, keys of 1 to 64 characters (letters, digits, `_`, `.`, `-`), values of at most 500 characters. |

<!-- 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: grant-level-5-user-42" \
  -H "Content-Type: application/json" \
  -d '{"currency": "gems", "amount": 100, "reason": "level_up", "metadata": {"level": "5"}}'
```
```javascript tab="Node.js"
const { entry, balance } = await gamecoin.wallet.grant(
  player,
  { currency: "gems", amount: 100, reason: "level_up", metadata: { level: "5" } },
  { idempotencyKey: "grant-level-5-user-42" },
);
console.log(entry.id, entry.bonusDelta, balance.total);
```
```python tab="Python"
movement = gamecoin.wallet.grant(
    player,
    currency="gems",
    amount=100,
    reason="level_up",
    metadata={"level": "5"},
    idempotency_key="grant-level-5-user-42",
)
print(movement.entry.id, movement.entry.bonus_delta, movement.balance.total)
```
```php tab="PHP"
$movement = $gamecoin->wallet->grant(
    $player,
    ['currency' => 'gems', 'amount' => 100, 'reason' => 'level_up', 'metadata' => ['level' => '5']],
    ['idempotency_key' => 'grant-level-5-user-42'],
);
echo $movement->entry->id, ' ', $movement->entry->bonusDelta, ' ', $movement->balance->total, "\n";
```
```go tab="Go"
movement, err := client.Wallet.Grant(ctx, player,
	gamecoin.GrantParams{Currency: "gems", Amount: 100, Reason: "level_up", Metadata: map[string]string{"level": "5"}},
	gamecoin.WithIdempotencyKey("grant-level-5-user-42"))
check(err)
fmt.Println(movement.Entry.ID, movement.Entry.BonusDelta, movement.Balance.Total)
```
```csharp tab="C#"
var movement = await gamecoin.Wallet.GrantAsync(
    player,
    new GrantRequest { Currency = "gems", Amount = 100, Reason = "level_up", Metadata = new Dictionary<string, string> { ["level"] = "5" } },
    new RequestOptions { IdempotencyKey = "grant-level-5-user-42" });
Console.WriteLine($"{movement.Entry.Id} {movement.Entry.BonusDelta} {movement.Balance.Total}");
```
<!-- tabs:end -->

**Response** `200 OK`: the ledger entry that was written, and the new balance of the currency.

| Field | Type | Description |
|---|---|---|
| `entry` | [LedgerEntry](/docs/api/objects#ledgerentry) | The entry: type `grant`, with your `reason` and `metadata`. |
| `balance` | [Balance](/docs/api/objects#balance) | The balance of the currency after the grant. |

```json
{
  "entry": {
    "id": "6ac4214bae33663917ec0ca6",
    "type": "grant",
    "currency": "gems",
    "paidDelta": 0,
    "bonusDelta": 100,
    "balanceAfter": { "paid": 0, "bonus": 100, "total": 100 },
    "reason": "level_up",
    "metadata": { "level": "5" },
    "ref": {},
    "createdAt": "2026-10-05T22:14:35.965Z"
  },
  "balance": { "currency": "gems", "paid": 0, "bonus": 100, "total": 100 }
}
```

Send the same request again with the same key, and you get the same answer with the header `Idempotent-Replayed: true`, without a second grant:

```bash
curl -i -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Idempotency-Key: grant-level-5-user-42" \
  -H "Content-Type: application/json" \
  -d '{"currency": "gems", "amount": 100, "reason": "level_up", "metadata": {"level": "5"}}'
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Idempotent-Replayed: true

{
  "entry": {
    "id": "6ac4214bae33663917ec0ca6",
    "type": "grant",
    "currency": "gems",
    "paidDelta": 0,
    "bonusDelta": 100,
    "balanceAfter": { "paid": 0, "bonus": 100, "total": 100 },
    "reason": "level_up",
    "metadata": { "level": "5" },
    "ref": {},
    "createdAt": "2026-10-05T22:14:35.965Z"
  },
  "balance": { "currency": "gems", "paid": 0, "bonus": 100, "total": 100 }
}
```

**Errors**

| Status | Code | When | What to do |
|---|---|---|---|
| 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing or empty | Send one: see [Idempotency](/docs/api#idempotency). |
| 400 | `VALIDATION_FAILED` | An unknown field (`paid`, `bonus`…); `amount` not an integer between 1 and 10¹²; `currency` malformed; `reason` too long; `metadata` with more than 20 entries (`METADATA_TOO_LARGE`), an invalid key or a value that is not a string; a key that is not 1 to 100 printable ASCII characters | Read `details.fieldErrors`. |
| 403 | `PLAYER_BLOCKED` | The player is blocked | Nothing can be written for this player until the player is unblocked. |
| 404 | `NOT_FOUND` | The currency is not in the catalog (or is archived), or a GameCoin id does not exist | Check the code against [Get the catalog](/docs/api/catalog#get-the-catalog). |
| 409 | `IDEMPOTENCY_CONFLICT` | The key was already used with a different request | Use a new key for a new grant, and the same key only to retry the same one. |
| 409 | `LIMIT_REACHED` | The balance would exceed 1,000,000,000,000,000 | Grant less. |

```json
{
  "error": {
    "code": "IDEMPOTENCY_CONFLICT",
    "message": "Idempotency-Key already used with a different request"
  }
}
```

**Notes**

- A grant **always credits the `bonus` part**, whatever the currency. `paid` coins only come from packs bought with real money, and a body field named `paid` is refused. The one exception is a player in debt (see [Get a wallet](#get-a-wallet)): the credit repays the debt first, and the entry's `paidDelta` and `bonusDelta` show where it went.
- Derive the key from the event you reward, such as `quest-17-user-42`, so that a job that runs twice grants once. The SDKs generate a key for you if you give none, and expose the header as `replayed` (`Replayed` in Go and C#) on the result.
- `reason` and `metadata` are for you: they come back in the ledger. Keep a quest id or a reward name there, never anything personal.
- The first write on an unknown `ext:` player creates it.

## Spend currency

Debits a currency from a player: they use coins, you charge for a revive, an entry fee, an unlock. To sell an **item** for coins, use [Buy an item with currency](/docs/api/purchases#buy-an-item-with-currency): it debits and delivers in one step.

```endpoint
POST /players/{player}/wallet/spend
```

**Authentication:** secret key. **Idempotency:** required (`Idempotency-Key` header).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `player` | string | A GameCoin player id, or `ext:<your id>` URL-encoded once. An unknown `ext:` player is created, with an empty wallet. |

**Body**

| Field | Type | Required | Rules |
|---|---|---|---|
| `currency` | string | yes | A currency code of the catalog (2 to 24 characters, lowercase). |
| `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. |
| `metadata` | object | no | Free `string` to `string` pairs: at most 20 entries, keys of 1 to 64 characters (letters, digits, `_`, `.`, `-`), values of at most 500 characters. |

<!-- tabs:start -->
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/spend" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Idempotency-Key: spend-revive-user-42" \
  -H "Content-Type: application/json" \
  -d '{"currency": "gems", "amount": 30, "reason": "revive"}'
```
```javascript tab="Node.js"
const { entry, balance } = await gamecoin.wallet.spend(
  player,
  { currency: "gems", amount: 30, reason: "revive" },
  { idempotencyKey: "spend-revive-user-42" },
);
console.log(entry.bonusDelta, entry.paidDelta, balance.total);
```
```python tab="Python"
movement = gamecoin.wallet.spend(
    player,
    currency="gems",
    amount=30,
    reason="revive",
    idempotency_key="spend-revive-user-42",
)
print(movement.entry.bonus_delta, movement.entry.paid_delta, movement.balance.total)
```
```php tab="PHP"
$movement = $gamecoin->wallet->spend(
    $player,
    ['currency' => 'gems', 'amount' => 30, 'reason' => 'revive'],
    ['idempotency_key' => 'spend-revive-user-42'],
);
echo $movement->entry->bonusDelta, ' ', $movement->entry->paidDelta, ' ', $movement->balance->total, "\n";
```
```go tab="Go"
movement, err := client.Wallet.Spend(ctx, player,
	gamecoin.SpendParams{Currency: "gems", Amount: 30, Reason: "revive"},
	gamecoin.WithIdempotencyKey("spend-revive-user-42"))
check(err)
fmt.Println(movement.Entry.BonusDelta, movement.Entry.PaidDelta, movement.Balance.Total)
```
```csharp tab="C#"
var movement = await gamecoin.Wallet.SpendAsync(
    player,
    new SpendRequest { Currency = "gems", Amount = 30, Reason = "revive" },
    new RequestOptions { IdempotencyKey = "spend-revive-user-42" });
Console.WriteLine($"{movement.Entry.BonusDelta} {movement.Entry.PaidDelta} {movement.Balance.Total}");
```
<!-- tabs:end -->

**Response** `200 OK`: the ledger entry that was written (type `spend`, with negative deltas) and the new balance.

| Field | Type | Description |
|---|---|---|
| `entry` | [LedgerEntry](/docs/api/objects#ledgerentry) | The entry. `bonusDelta` and `paidDelta` say where the coins were taken from. |
| `balance` | [Balance](/docs/api/objects#balance) | The balance of the currency after the spend. |

```json
{
  "entry": {
    "id": "6ac4214ceab6d4425553aff6",
    "type": "spend",
    "currency": "gems",
    "paidDelta": 0,
    "bonusDelta": -30,
    "balanceAfter": { "paid": 0, "bonus": 70, "total": 70 },
    "reason": "revive",
    "metadata": {},
    "ref": {},
    "createdAt": "2026-10-05T22:14:36.491Z"
  },
  "balance": { "currency": "gems", "paid": 0, "bonus": 70, "total": 70 }
}
```

When the balance is too low, nothing is spent and the answer is `409 INSUFFICIENT_FUNDS`, with what was needed and what the player has. Here is how to handle it:

<!-- tabs:start -->
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/spend" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Idempotency-Key: spend-unlock-user-42" \
  -H "Content-Type: application/json" \
  -w "\nHTTP %{http_code}\n" \
  -d '{"currency": "gems", "amount": 5000, "reason": "unlock"}'
```
```javascript tab="Node.js"
import { ErrorCode, GameCoinError } from "@apilow/gamecoin";

try {
  await gamecoin.wallet.spend(player, { currency: "gems", amount: 5000, reason: "unlock" });
} catch (error) {
  if (error instanceof GameCoinError && error.code === ErrorCode.INSUFFICIENT_FUNDS) {
    console.log(`needs ${error.details.required}, has ${error.details.available}`);
  } else {
    throw error;
  }
}
```
```python tab="Python"
from gamecoin import ErrorCode, GameCoinError

try:
    gamecoin.wallet.spend(player, currency="gems", amount=5000, reason="unlock")
except GameCoinError as error:
    if error.code == ErrorCode.INSUFFICIENT_FUNDS:
        print(f"needs {error.details['required']}, has {error.details['available']}")
    else:
        raise
```
```php tab="PHP"
use Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoinException;

try {
    $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 5000, 'reason' => 'unlock']);
} catch (GameCoinException $e) {
    if ($e->errorCode === ErrorCode::INSUFFICIENT_FUNDS) {
        echo "needs {$e->details['required']}, has {$e->details['available']}\n";
    } else {
        throw $e;
    }
}
```
```go tab="Go"
_, err = client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 5000, Reason: "unlock"})
var gcErr *gamecoin.Error
if errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeInsufficientFunds {
	required, _ := gcErr.DetailInt64("required")
	available, _ := gcErr.DetailInt64("available")
	fmt.Printf("needs %d, has %d\n", required, available)
} else {
	check(err)
}
```
```csharp tab="C#"
try
{
    await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 5000, Reason = "unlock" });
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.InsufficientFunds)
{
    Console.WriteLine($"needs {ex.Details["required"].GetInt64()}, has {ex.Details["available"].GetInt64()}");
}
```
<!-- tabs:end -->

```json
{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Insufficient funds",
    "details": { "required": 5000, "available": 70 }
  }
}
```

**Errors**

| Status | Code | When | What to do |
|---|---|---|---|
| 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing or empty | Send one: see [Idempotency](/docs/api#idempotency). |
| 400 | `VALIDATION_FAILED` | An unknown field; `amount` not an integer between 1 and 10¹²; `currency` malformed; `reason` too long; `metadata` invalid; a key that is not 1 to 100 printable ASCII characters | Read `details.fieldErrors`. |
| 403 | `PLAYER_BLOCKED` | The player is blocked | Nothing can be written for this player until the player is unblocked. |
| 404 | `NOT_FOUND` | The currency is not in the catalog, or a GameCoin id does not exist | Check the code against [Get the catalog](/docs/api/catalog#get-the-catalog). |
| 409 | `INSUFFICIENT_FUNDS` | The balance is lower than `amount`: `details.required` and `details.available` | Offer the player a way to get more coins, such as 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**

- **Replay.** The same key with the same request returns the original response again, with the header `Idempotent-Replayed: true`, and changes nothing.
- **Bonus coins are spent first**, then `paid` ones, which is why the example above leaves `paidDelta` at 0. A game can reverse this order in its settings (« Ordre de dépense » in the dashboard, which is in French for now). The entry records both deltas.
- A spend is **all or nothing**: it never takes part of the amount and never leaves a negative balance. After a refund left a player in debt, their `available` amount is 0.
- A request that failed with `INSUFFICIENT_FUNDS` is not stored: once the player has more coins you can retry it with the same key.
- A spend on an unknown `ext:` player creates the player, then fails with `INSUFFICIENT_FUNDS`.
- The browser can spend too, with the player's own token: [Spend currency](/docs/api/client#spend-currency) in the client API.
