Concepts
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.
On this page
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.
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"const wallet = await gamecoin.wallet.get(ext("user-42"));
console.log(wallet.balances);wallet = gamecoin.wallet.get(ext("user-42"))
print(wallet.balances)$wallet = $gamecoin->wallet->get(PlayerRef::ext('user-42'));
echo json_encode($wallet->balances), "
";wallet, err := client.Wallet.Get(ctx, gamecoin.Ext("user-42"))
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", wallet.Balances)var wallet = await gamecoin.Wallet.GetAsync(PlayerRef.Ext("user-42"));
foreach (var balance in wallet.Balances)
{
Console.WriteLine(balance);
}{
"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: free coins, never paid |
This is a grant and the entry it leaves:
/players/{player}/wallet/grantcurl -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"}}'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 50movement = 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$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 50movement, 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 50var 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{
"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:
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"}'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 150movement = 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$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 150movement, 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 150var 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{
"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.
{
"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:
{
"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:
{
"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_FUNDSanswers withavailable: 0. - A credit pays the debt first. Granting 100 gems to the player above gives
total: -300, not 100. bonusis never negative. A negativepaidalways comes withbonus: 0.
How to read what a refund could not take back is on Refunds.
Read the ledger
GET /players/{player}/ledger returns the entries of a player, newest first.
/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 |
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?currency=gems&limit=2" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"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);
}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)$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";
}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)
}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}");
}The answer ends with the cursor of the next page. On the last page, nextCursor is null:
{
"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: make every write safe to retry.
- Refunds: take back an order and read what happened.
- Errors: every code the API can answer.