API reference
Wallet API
Read a player's balances, grant them free currency and spend it from your backend with the server API.
On this page
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. Selling a currency for real money is a checkout, not a grant. The examples on this page continue from the setup.
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.
/players/{player}/walletAuthentication: secret key. Idempotency: not needed.
Path parameters
| Parameter | Type | Description |
|---|---|---|
player | string | A GameCoin player id, or ext:<your id> URL-encoded once. |
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"const wallet = await gamecoin.wallet.get(player);
for (const { currency, paid, bonus, total } of wallet.balances) console.log(currency, paid, bonus, total);wallet = gamecoin.wallet.get(player)
for balance in wallet.balances:
print(balance.currency, balance.paid, balance.bonus, balance.total)$wallet = $gamecoin->wallet->get($player);
foreach ($wallet->balances as $balance) {
echo "{$balance->currency}: paid {$balance->paid}, bonus {$balance->bonus}, total {$balance->total}\n";
}wallet, err := client.Wallet.Get(ctx, player)
check(err)
for _, b := range wallet.Balances {
fmt.Println(b.Currency, b.Paid, b.Bonus, b.Total)
}var wallet = await gamecoin.Wallet.GetAsync(player);
foreach (var balance in wallet.Balances) Console.WriteLine($"{balance.Currency} {balance.Paid} {balance.Bonus} {balance.Total}");Response 200 OK: a Wallet.
{
"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
balanceshas one line per active currency, zeros included: heregoldis there although the player never received any. A currency that is archived disappears from the list.totalispaid + bonus. Onlypaidcomes 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 that you created.
/players/{player}/wallet/grantAuthentication: 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. |
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"}}'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);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)$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";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)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}");Response 200 OK: the ledger entry that was written, and the new balance of the currency.
| Field | Type | Description |
|---|---|---|
entry | LedgerEntry | The entry: type grant, with your reason and metadata. |
balance | Balance | The balance of the currency after the grant. |
{
"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:
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/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. |
| 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. |
| 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. |
{
"error": {
"code": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key already used with a different request"
}
}Notes
- A grant always credits the
bonuspart, whatever the currency.paidcoins only come from packs bought with real money, and a body field namedpaidis refused. The one exception is a player in debt (see Get a wallet): the credit repays the debt first, and the entry'spaidDeltaandbonusDeltashow 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 asreplayed(Replayedin Go and C#) on the result. reasonandmetadataare 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: it debits and delivers in one step.
/players/{player}/wallet/spendAuthentication: 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. |
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"}'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);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)$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";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)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}");Response 200 OK: the ledger entry that was written (type spend, with negative deltas) and the new balance.
| Field | Type | Description |
|---|---|---|
entry | LedgerEntry | The entry. bonusDelta and paidDelta say where the coins were taken from. |
balance | Balance | The balance of the currency after the spend. |
{
"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:
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"}'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;
}
}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:
raiseuse 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;
}
}_, 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)
}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()}");
}{
"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. |
| 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. |
| 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
paidones, which is why the example above leavespaidDeltaat 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
availableamount is 0. - A request that failed with
INSUFFICIENT_FUNDSis 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 withINSUFFICIENT_FUNDS. - The browser can spend too, with the player's own token: Spend currency in the client API.