# Wallets and ledger

> How balances are stored, which coins are spent first, what a refund does to a balance, and how to read the history of every change.

## One balance, two buckets

A player has one balance per currency. Each balance holds two whole numbers: `paid` and `bonus`. Their sum is `total`. Reading a wallet returns one line per currency of the game, zeros included. The player must exist: a read for an `ext:` id that was never written answers `404 NOT_FOUND`, and the first write creates it.

<!-- 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(ext("user-42"));
console.log(wallet.balances);
```
```python tab="Python"
wallet = gamecoin.wallet.get(ext("user-42"))
print(wallet.balances)
```
```php tab="PHP"
$wallet = $gamecoin->wallet->get(PlayerRef::ext('user-42'));
echo json_encode($wallet->balances), "
";
```
```go tab="Go"
wallet, err := client.Wallet.Get(ctx, gamecoin.Ext("user-42"))
if err != nil {
	log.Fatal(err)
}
fmt.Printf("%+v\n", wallet.Balances)
```
```csharp tab="C#"
var wallet = await gamecoin.Wallet.GetAsync(PlayerRef.Ext("user-42"));
foreach (var balance in wallet.Balances)
{
    Console.WriteLine(balance);
}
```
<!-- tabs:end -->

```json title="Answer"
{
  "playerId": "6ac41e244870c94d92d6c0ec",
  "balances": [
    { "currency": "gems", "paid": 500, "bonus": 50, "total": 550 },
    { "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
  ]
}
```

| Bucket | What credits it |
|---|---|
| `paid` | The paid part of a pack the player bought (`amount` of a pack line) |
| `bonus` | A grant from your server, and the free part of a pack (`bonus` of a pack line) |

Amounts are whole numbers from 1 to 1 000 000 000 000 (10¹²) per operation. A balance can never go beyond 10¹⁵: an operation that would push it over answers `409 LIMIT_REACHED`.

> [!NOTE]
> A grant always credits the `bonus` bucket. You cannot credit `paid` from the API: `paid` only grows when a player pays for a pack. A body with a field `paid` is refused as an unknown field.

## Every change is a ledger entry

The ledger is an append-only list. Each change to a balance adds one entry, and no entry is ever edited or deleted. An entry carries the change in each bucket (`paidDelta`, `bonusDelta`), the balance right after (`balanceAfter`), a `reason`, your `metadata`, and a `ref` to the order or item it belongs to. Adding up the entries of a player always gives their balance.

| Type | Effect | Created by |
|---|---|---|
| `purchase_credit` | `paid` and `bonus` go up | A pack order that was delivered |
| `grant` | `bonus` goes up | `POST …/wallet/grant`, or a grant made from the dashboard |
| `spend` | Goes down | A spend, or the purchase of an item |
| `refund_clawback` | Goes down | A refund takes back what an order had credited |
| `chargeback_clawback` | Goes down | Reserved: nothing creates it yet |
| `adjustment` | Up or down | A manual correction made in the dashboard |
| `gift_code` | `bonus` goes up | A player redeemed a [gift code](/docs/api/codes#redeem-a-gift-code): free coins, never `paid` |

This is a grant and the entry it leaves:

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

<!-- 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: daily-reward-user-42-2026-10-05" \
  -H "Content-Type: application/json" \
  -d '{"currency":"gold","amount":50,"reason":"daily_reward","metadata":{"day":"3"}}'
```
```javascript tab="Node.js"
const { entry, balance } = await gamecoin.wallet.grant(
  ext("user-42"),
  { currency: "gold", amount: 50, reason: "daily_reward", metadata: { day: "3" } },
  { idempotencyKey: "daily-reward-user-42-2026-10-05" },
);
console.log(entry.type, entry.bonusDelta, balance.total); // grant 50 50
```
```python tab="Python"
movement = gamecoin.wallet.grant(
    ext("user-42"),
    currency="gold",
    amount=50,
    reason="daily_reward",
    metadata={"day": "3"},
    idempotency_key="daily-reward-user-42-2026-10-05",
)
print(movement.entry.type, movement.entry.bonus_delta, movement.balance.total)  # grant 50 50
```
```php tab="PHP"
$movement = $gamecoin->wallet->grant(
    PlayerRef::ext('user-42'),
    ['currency' => 'gold', 'amount' => 50, 'reason' => 'daily_reward', 'metadata' => ['day' => '3']],
    ['idempotency_key' => 'daily-reward-user-42-2026-10-05'],
);
echo $movement->entry->type, ' ', $movement->entry->bonusDelta, ' ', $movement->balance->total, "\n"; // grant 50 50
```
```go tab="Go"
movement, err := client.Wallet.Grant(
	ctx,
	gamecoin.Ext("user-42"),
	gamecoin.GrantParams{Currency: "gold", Amount: 50, Reason: "daily_reward", Metadata: map[string]string{"day": "3"}},
	gamecoin.WithIdempotencyKey("daily-reward-user-42-2026-10-05"),
)
if err != nil {
	log.Fatal(err)
}
fmt.Println(movement.Entry.Type, movement.Entry.BonusDelta, movement.Balance.Total) // grant 50 50
```
```csharp tab="C#"
var movement = await gamecoin.Wallet.GrantAsync(
    PlayerRef.Ext("user-42"),
    new GrantRequest { Currency = "gold", Amount = 50, Reason = "daily_reward", Metadata = new Dictionary<string, string> { ["day"] = "3" } },
    new RequestOptions { IdempotencyKey = "daily-reward-user-42-2026-10-05" });
Console.WriteLine($"{movement.Entry.Type} {movement.Entry.BonusDelta} {movement.Balance.Total}"); // grant 50 50
```
<!-- tabs:end -->

```json title="Answer"
{
  "entry": {
    "id": "6ac419eeb2d9fab61ce3b764",
    "type": "grant",
    "currency": "gold",
    "paidDelta": 0,
    "bonusDelta": 50,
    "balanceAfter": { "paid": 0, "bonus": 50, "total": 50 },
    "reason": "daily_reward",
    "metadata": { "day": "3" },
    "ref": {},
    "createdAt": "2026-10-05T21:43:10.359Z"
  },
  "balance": { "currency": "gold", "paid": 0, "bonus": 50, "total": 50 }
}
```

`reason` is free text up to 500 characters. `metadata` holds up to 20 string keys; both are only for you and come back unchanged in the ledger.

## Which coins are spent first

A spend takes the `bonus` coins first, then the `paid` coins. Free coins leave first because paid coins can still be refunded: keeping them longer keeps them refundable longer. A game can reverse this in its settings (« Ordre de dépense » in the dashboard, which is in French): spend `paid` first.

A player holds 500 paid and 50 free gems and spends 400:

<!-- 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: big-buy-user-42" \
  -H "Content-Type: application/json" \
  -d '{"currency":"gems","amount":400,"reason":"big-buy"}'
```
```javascript tab="Node.js"
const { entry, balance } = await gamecoin.wallet.spend(
  ext("user-42"),
  { currency: "gems", amount: 400, reason: "big-buy" },
  { idempotencyKey: "big-buy-user-42" },
);
console.log(entry.bonusDelta, entry.paidDelta, balance.total); // -50 -350 150
```
```python tab="Python"
movement = gamecoin.wallet.spend(
    ext("user-42"),
    currency="gems",
    amount=400,
    reason="big-buy",
    idempotency_key="big-buy-user-42",
)
print(movement.entry.bonus_delta, movement.entry.paid_delta, movement.balance.total)  # -50 -350 150
```
```php tab="PHP"
$movement = $gamecoin->wallet->spend(
    PlayerRef::ext('user-42'),
    ['currency' => 'gems', 'amount' => 400, 'reason' => 'big-buy'],
    ['idempotency_key' => 'big-buy-user-42'],
);
echo $movement->entry->bonusDelta, ' ', $movement->entry->paidDelta, ' ', $movement->balance->total, "\n"; // -50 -350 150
```
```go tab="Go"
movement, err := client.Wallet.Spend(
	ctx,
	gamecoin.Ext("user-42"),
	gamecoin.SpendParams{Currency: "gems", Amount: 400, Reason: "big-buy"},
	gamecoin.WithIdempotencyKey("big-buy-user-42"),
)
if err != nil {
	log.Fatal(err)
}
fmt.Println(movement.Entry.BonusDelta, movement.Entry.PaidDelta, movement.Balance.Total) // -50 -350 150
```
```csharp tab="C#"
var movement = await gamecoin.Wallet.SpendAsync(
    PlayerRef.Ext("user-42"),
    new SpendRequest { Currency = "gems", Amount = 400, Reason = "big-buy" },
    new RequestOptions { IdempotencyKey = "big-buy-user-42" });
Console.WriteLine($"{movement.Entry.BonusDelta} {movement.Entry.PaidDelta} {movement.Balance.Total}"); // -50 -350 150
```
<!-- tabs:end -->

```json title="Answer"
{
  "entry": {
    "id": "6ac41a1dcdd4ba50e97ba362",
    "type": "spend",
    "currency": "gems",
    "paidDelta": -350,
    "bonusDelta": -50,
    "balanceAfter": { "paid": 150, "bonus": 0, "total": 150 },
    "reason": "big-buy",
    "metadata": {},
    "ref": {},
    "createdAt": "2026-10-05T21:43:57.311Z"
  },
  "balance": { "currency": "gems", "paid": 150, "bonus": 0, "total": 150 }
}
```

The 50 free gems went first, then 350 paid ones. One spend, one entry, two buckets touched.

## A spend never goes below zero

If the balance is too low, the spend is refused with `409 INSUFFICIENT_FUNDS` and nothing changes. The error says how much was needed and how much was available.

```json title="Answer to a spend that is too high"
{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Insufficient funds",
    "details": { "required": 50, "available": 10 }
  }
}
```

Use these two numbers to offer a pack: the player is `required - available` short.

Every operation on a wallet is serialized per player. Five spends of 40 gems sent at the same moment to a balance of 90 gems give exactly two successes and three `INSUFFICIENT_FUNDS`; no spend can use the same coins twice.

## What a refund does to a balance

A refund takes back what the order credited: the `paid` and `bonus` coins of its pack lines, plus its items. The player may already have spent some of them. What happens then depends on a game setting (« Après un remboursement » in the dashboard):

| Setting | Name in the dashboard | Result |
|---|---|---|
| **Allow a debt** (default) | « Autoriser une dette » | Everything is taken back. What the player no longer has becomes a **debt**: the balance goes below zero. |
| **Stop at zero** | « S'arrêter à zéro » | Only what is left is taken. The balance never goes below zero; the difference is lost to you, the studio. |

With the default, a player who bought 500 gems (and received 50 free ones), spent 400, then got refunded, ends at -400:

```json title="The wallet after the refund"
{
  "playerId": "6ac419fa60e877541898f38d",
  "balances": [
    { "currency": "gems", "paid": -400, "bonus": 0, "total": -400 },
    { "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
  ]
}
```

The refund left one `refund_clawback` entry. Its `ref.orderId` names the order, and the whole 550 appears in `paidDelta` because the debt is carried by `paid`:

```json title="The refund_clawback entry"
{
  "id": "6ac41a2706b31e2c42a63f37",
  "type": "refund_clawback",
  "currency": "gems",
  "paidDelta": -550,
  "bonusDelta": 0,
  "balanceAfter": { "paid": -400, "bonus": 0, "total": -400 },
  "reason": null,
  "metadata": {},
  "ref": { "orderId": "6ac419fbdbe02af6c99c3d47" },
  "createdAt": "2026-10-05T21:44:07.015Z"
}
```

A debt follows three rules:

- A player in debt cannot spend: `INSUFFICIENT_FUNDS` answers with `available: 0`.
- A credit pays the debt first. Granting 100 gems to the player above gives `total: -300`, not 100.
- `bonus` is never negative. A negative `paid` always comes with `bonus: 0`.

How to read what a refund could not take back is on [Refunds](/docs/guides/refunds).

## Read the ledger

`GET /players/{player}/ledger` returns the entries of a player, newest first.

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

| Query | Default | Meaning |
|---|---|---|
| `currency` | all | Only the entries of this currency |
| `limit` | 20 | Page size, from 1 to 100 |
| `cursor` | none | The `nextCursor` of the previous page |

<!-- tabs:start -->
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?currency=gems&limit=2" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const page = await gamecoin.ledger.list(ext("user-42"), { currency: "gems", limit: 2 });
console.log(page.entries.length, page.nextCursor !== null);

// Or let the SDK follow the cursors and fetch each page only when you need it.
for await (const entry of gamecoin.ledger.iterate(ext("user-42"), { currency: "gems", limit: 50 })) {
  console.log(entry.createdAt.toISOString(), entry.type, entry.paidDelta + entry.bonusDelta);
}
```
```python tab="Python"
page = gamecoin.ledger.list(ext("user-42"), currency="gems", limit=2)
print(len(page.entries), page.next_cursor is not None)

# Or let the SDK follow the cursors and fetch each page only when you need it.
for entry in gamecoin.ledger.iterate(ext("user-42"), currency="gems", limit=50):
    print(entry.created_at.isoformat(), entry.type, entry.paid_delta + entry.bonus_delta)
```
```php tab="PHP"
$page = $gamecoin->ledger->list(PlayerRef::ext('user-42'), ['currency' => 'gems', 'limit' => 2]);
echo count($page->entries), ' ', var_export($page->nextCursor !== null, true), "\n";

// Or let the SDK follow the cursors and fetch each page only when you need it.
foreach ($gamecoin->ledger->iterate(PlayerRef::ext('user-42'), ['currency' => 'gems', 'limit' => 50]) as $entry) {
    echo $entry->createdAt->format(DATE_ATOM), ' ', $entry->type, ' ', $entry->paidDelta + $entry->bonusDelta, "\n";
}
```
```go tab="Go"
page, err := client.Ledger.List(ctx, gamecoin.Ext("user-42"), &gamecoin.LedgerListParams{Currency: "gems", Limit: 2})
if err != nil {
	log.Fatal(err)
}
fmt.Println(len(page.Entries), page.NextCursor != nil)

// Or let the SDK follow the cursors and fetch each page only when you need it.
it := client.Ledger.Iterate(ctx, gamecoin.Ext("user-42"), &gamecoin.LedgerListParams{Currency: "gems", Limit: 50})
for it.Next() {
	entry := it.Entry()
	fmt.Println(entry.CreatedAt.Format(time.RFC3339), entry.Type, entry.PaidDelta+entry.BonusDelta)
}
if err := it.Err(); err != nil {
	log.Fatal(err)
}
```
```csharp tab="C#"
var page = await gamecoin.Ledger.ListAsync(PlayerRef.Ext("user-42"), new LedgerQuery { Currency = "gems", Limit = 2 });
Console.WriteLine($"{page.Entries.Count} {page.NextCursor is not null}");

// Or let the SDK follow the cursors and fetch each page only when you need it.
await foreach (var entry in gamecoin.Ledger.IterateAsync(PlayerRef.Ext("user-42"), new LedgerQuery { Currency = "gems", Limit = 50 }))
{
    Console.WriteLine($"{entry.CreatedAt:u} {entry.Type} {entry.PaidDelta + entry.BonusDelta}");
}
```
<!-- tabs:end -->

The answer ends with the cursor of the next page. On the last page, `nextCursor` is `null`:

```json title="Answer (shortened)"
{
  "entries": [ { "id": "6ac41a2706b31e2c42a63f37", "type": "refund_clawback", "…": "…" }, { "id": "6ac41a1dcdd4ba50e97ba362", "type": "spend", "…": "…" } ],
  "nextCursor": "MTc5MTIzNjYzNzMxMTo2YWM0MWExZGNkZDRiYTUwZTk3YmEzNjI"
}
```

A cursor is opaque. Pass it back as it is, with the same `currency`. An invalid cursor answers `400 VALIDATION_FAILED` with the field error `CURSOR_INVALID`.

## Where next

- [Idempotency](/docs/concepts/idempotency): make every write safe to retry.
- [Refunds](/docs/guides/refunds): take back an order and read what happened.
- [Errors](/docs/concepts/errors): every code the API can answer.
