Skip to content
Skip the menu

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.

View as Markdown

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.

LangageLanguage
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
Answer
{
  "playerId": "6ac41e244870c94d92d6c0ec",
  "balances": [
    { "currency": "gems", "paid": 500, "bonus": 50, "total": 550 },
    { "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
  ]
}
BucketWhat credits it
paidThe paid part of a pack the player bought (amount of a pack line)
bonusA 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.

TypeEffectCreated by
purchase_creditpaid and bonus go upA pack order that was delivered
grantbonus goes upPOST …/wallet/grant, or a grant made from the dashboard
spendGoes downA spend, or the purchase of an item
refund_clawbackGoes downA refund takes back what an order had credited
chargeback_clawbackGoes downReserved: nothing creates it yet
adjustmentUp or downA manual correction made in the dashboard
gift_codebonus goes upA player redeemed a gift code: free coins, never paid

This is a grant and the entry it leaves:

POST/players/{player}/wallet/grant
LangageLanguage
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"}}'
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:

LangageLanguage
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"}'
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.

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):

SettingName in the dashboardResult
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:

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:

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.

Read the ledger

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

GET/players/{player}/ledger
QueryDefaultMeaning
currencyallOnly the entries of this currency
limit20Page size, from 1 to 100
cursornoneThe nextCursor of the previous page
LangageLanguage
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?currency=gems&limit=2" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"

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

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: make every write safe to retry.
  • Refunds: take back an order and read what happened.
  • Errors: every code the API can answer.